291 lines
10 KiB
Go
291 lines
10 KiB
Go
package business
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
"kra/internal/biz/business"
|
|
)
|
|
|
|
type reportBuildRepo struct{ data Provider }
|
|
|
|
func NewReportBuildRepo(data Provider) business.ReportBuildRepo { return &reportBuildRepo{data: data} }
|
|
|
|
type reportBuildLockPO struct {
|
|
Kind string `gorm:"size:16;primaryKey"`
|
|
StatDate string `gorm:"type:date;primaryKey"`
|
|
}
|
|
|
|
func (reportBuildLockPO) TableName() string { return "sys_business_report_build_locks" }
|
|
|
|
type reportEventPO struct {
|
|
SourceDB string `gorm:"column:source_db"`
|
|
UserID, PromoteID, GameID *int64
|
|
ServerID, PayAmount string
|
|
}
|
|
|
|
func toReportEvent(p reportEventPO) business.ReportEvent {
|
|
return business.ReportEvent{SourceDB: p.SourceDB, UserID: p.UserID, PromoteID: p.PromoteID, GameID: p.GameID, ServerNum: p.ServerID, Amount: p.PayAmount}
|
|
}
|
|
func (r *reportBuildRepo) Rebuild(ctx context.Context, kind business.ReportBuildKind, day time.Time, compile func(*business.ReportInput) ([]*business.ReportBuiltRow, error)) error {
|
|
if kind != business.DailyReport && kind != business.ServerReport {
|
|
return business.ErrAnalyticsInput
|
|
}
|
|
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
reportTable, keyTable := "tab_sw_report_day", "tab_sw_report_day_key"
|
|
rawTable := "tab_user_day_login"
|
|
if kind == business.ServerReport {
|
|
reportTable, keyTable = "tab_sw_report_server_day", "tab_sw_report_server_day_key"
|
|
rawTable = "tab_user_play_info"
|
|
}
|
|
available, err := businessTablesAvailable(tx, reportTable, keyTable, rawTable, "tab_user", "tab_spend", "tab_game")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !available {
|
|
return business.ErrReportBuildUnavailable
|
|
}
|
|
statDate := day.Format("2006-01-02")
|
|
lock := reportBuildLockPO{Kind: string(kind), StatDate: statDate}
|
|
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&lock).Error; err != nil {
|
|
return business.ErrStorage
|
|
}
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("kind=? AND stat_date=?", lock.Kind, lock.StatDate).Take(&lock).Error; err != nil {
|
|
return business.ErrStorage
|
|
}
|
|
input, err := loadReportInput(tx, kind, day)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, err := compile(input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Exec("DELETE FROM "+keyTable+" WHERE stat_date=?", statDate).Error; err != nil {
|
|
return business.ErrStorage
|
|
}
|
|
if err := tx.Exec("DELETE FROM "+reportTable+" WHERE stat_date=?", statDate).Error; err != nil {
|
|
return business.ErrStorage
|
|
}
|
|
reports := make([]map[string]any, 0, len(rows))
|
|
keys := []map[string]any{}
|
|
for _, row := range rows {
|
|
base := func() map[string]any {
|
|
out := map[string]any{"stat_date": statDate, "source_db": row.SourceDB, "promote_id": row.PromoteID, "game_id": row.GameID, "game_name": row.GameName, "dedup_mode": row.Mode}
|
|
if kind == business.ServerReport {
|
|
out["server_num"] = row.ServerNum
|
|
}
|
|
return out
|
|
}
|
|
report := base()
|
|
report["new_register_cnt"], report["active_cnt"], report["pay_cnt"], report["total_pay"] = row.Counts.Register, row.Counts.Active, row.Counts.Pay, row.Pay
|
|
if kind == business.DailyReport {
|
|
report["new_pay_cnt"] = row.Counts.NewPay
|
|
}
|
|
reports = append(reports, report)
|
|
for metric := 1; metric <= 4; metric++ {
|
|
for _, identity := range row.Keys[metric] {
|
|
key := base()
|
|
key["metric"], key["identity_key"] = metric, identity
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
}
|
|
// 汇总和身份键始终同事务替换,失败或取消会恢复原日报。
|
|
for _, batch := range []struct {
|
|
table string
|
|
rows []map[string]any
|
|
}{{reportTable, reports}, {keyTable, keys}} {
|
|
if len(batch.rows) > 0 {
|
|
if err := tx.Table(batch.table).CreateInBatches(batch.rows, 100).Error; err != nil {
|
|
return business.ErrStorage
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
func loadReportInput(db *gorm.DB, kind business.ReportBuildKind, day time.Time) (*business.ReportInput, error) {
|
|
input := &business.ReportInput{Users: map[business.ReportUserKey]business.ReportUser{}, Games: map[business.AnalyticsKey]string{}}
|
|
begin, end := day.Unix(), day.AddDate(0, 0, 1).Unix()-1
|
|
userKeys := map[business.ReportUserKey]bool{}
|
|
unknownUserIDs, gameIDs := map[int64]bool{}, map[int64]bool{}
|
|
collect := func(rows []reportEventPO, target *[]business.ReportEvent) {
|
|
for _, po := range rows {
|
|
*target = append(*target, toReportEvent(po))
|
|
if po.UserID != nil {
|
|
if strings.TrimSpace(po.SourceDB) == "" {
|
|
unknownUserIDs[*po.UserID] = true
|
|
} else {
|
|
userKeys[business.ReportUserKey{SourceDB: po.SourceDB, ID: *po.UserID}] = true
|
|
}
|
|
}
|
|
if po.GameID != nil {
|
|
gameIDs[*po.GameID] = true
|
|
} else {
|
|
gameIDs[0] = true
|
|
}
|
|
}
|
|
}
|
|
if kind == business.DailyReport {
|
|
cast := "CAST(register_time AS INTEGER)"
|
|
if db.Dialector.Name() == "mysql" {
|
|
cast = "CAST(register_time AS UNSIGNED)"
|
|
}
|
|
var registers, actives []reportEventPO
|
|
registerQuery, err := reportRegistrationQuery(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := registerQuery.Select("id AS user_id,source_db,promote_id,fgame_id AS game_id").Where("puid=0").Where(cast+" BETWEEN ? AND ?", begin, end).Scan(®isters).Error; err != nil {
|
|
return nil, business.ErrStorage
|
|
}
|
|
if err := db.Table("tab_user_day_login").Select("user_id,source_db,promote_id,game_id").Where("login_time BETWEEN ? AND ?", begin, end).Scan(&actives).Error; err != nil {
|
|
return nil, business.ErrStorage
|
|
}
|
|
collect(registers, &input.Registers)
|
|
collect(actives, &input.Actives)
|
|
} else {
|
|
for _, query := range []struct {
|
|
column string
|
|
target *[]business.ReportEvent
|
|
}{{"create_time", &input.Registers}, {"play_time", &input.Actives}} {
|
|
var rows []reportEventPO
|
|
if err := db.Table("tab_user_play_info").Select("user_id,source_db,promote_id,game_id,server_id").Where("server_id<>''").Where(query.column+" BETWEEN ? AND ?", begin, end).Scan(&rows).Error; err != nil {
|
|
return nil, business.ErrStorage
|
|
}
|
|
collect(rows, query.target)
|
|
}
|
|
}
|
|
pay := db.Table("tab_spend").Where("pay_status=1 AND pay_time BETWEEN ? AND ?", begin, end)
|
|
columns := "user_id,source_db,promote_id,game_id,pay_amount"
|
|
if kind == business.ServerReport {
|
|
pay = pay.Where("server_id<>''")
|
|
columns += ",server_id"
|
|
}
|
|
var pays []reportEventPO
|
|
if err := pay.Select(columns).Scan(&pays).Error; err != nil {
|
|
return nil, business.ErrStorage
|
|
}
|
|
collect(pays, &input.Payments)
|
|
var err error
|
|
input.Users, err = loadReportUsers(db, userKeys, unknownUserIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make([]int64, 0, len(gameIDs))
|
|
for id := range gameIDs {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
for start := 0; start < len(ids); start += 500 {
|
|
var games []struct {
|
|
ID int64
|
|
SourceDB string `gorm:"column:source_db"`
|
|
GameName string
|
|
}
|
|
if err := db.Table("tab_game").Select("id,source_db,game_name").Where("id IN ?", ids[start:min(start+500, len(ids))]).Scan(&games).Error; err != nil {
|
|
return nil, business.ErrStorage
|
|
}
|
|
for _, po := range games {
|
|
if strings.TrimSpace(po.SourceDB) != "" && strings.TrimSpace(po.GameName) != "" {
|
|
input.Games[business.AnalyticsKey{SourceDB: po.SourceDB, ID: po.ID}] = po.GameName
|
|
}
|
|
}
|
|
}
|
|
return input, nil
|
|
}
|
|
|
|
// 已知平台的事件按完整主键读用户,避免将其他平台同号的大行一起回表。
|
|
// 只有缺平台的事件读取全部同号用户,以保留旧逻辑的唯一主账号归属判断。
|
|
func loadReportUsers(db *gorm.DB, known map[business.ReportUserKey]bool, unknown map[int64]bool) (map[business.ReportUserKey]business.ReportUser, error) {
|
|
result := map[business.ReportUserKey]business.ReportUser{}
|
|
read := func(query *gorm.DB) error {
|
|
var users []struct {
|
|
ID int64
|
|
SourceDB string `gorm:"column:source_db"`
|
|
Puid, PromoteID *int64
|
|
IDCard string `gorm:"column:idcard"`
|
|
}
|
|
if err := query.Select("id,source_db,puid,promote_id,idcard").Scan(&users).Error; err != nil {
|
|
return business.ErrStorage
|
|
}
|
|
for _, po := range users {
|
|
key := business.ReportUserKey{SourceDB: po.SourceDB, ID: po.ID}
|
|
result[key] = business.ReportUser{ReportUserKey: key, ParentID: po.Puid, PromoteID: po.PromoteID, IDCard: po.IDCard}
|
|
}
|
|
return nil
|
|
}
|
|
bySource := make(map[string][]int64)
|
|
for key := range known {
|
|
if !unknown[key.ID] {
|
|
bySource[key.SourceDB] = append(bySource[key.SourceDB], key.ID)
|
|
}
|
|
}
|
|
sources := make([]string, 0, len(bySource))
|
|
for source := range bySource {
|
|
sources = append(sources, source)
|
|
}
|
|
sort.Strings(sources)
|
|
// MySQL 5.7 的多列 IN 在部分批次退化为全表扫描;固定平台后
|
|
// 对 ID 做范围查询,仍完整匹配复合主键,并按 ID 顺序减少随机读页。
|
|
for _, source := range sources {
|
|
ids := bySource[source]
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
for start := 0; start < len(ids); start += 500 {
|
|
if err := read(db.Table("tab_user").Where("source_db=? AND id IN ?", source, ids[start:min(start+500, len(ids))])); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
ids := make([]int64, 0, len(unknown))
|
|
for id := range unknown {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
for start := 0; start < len(ids); start += 500 {
|
|
if err := read(db.Table("tab_user").Where("id IN ?", ids[start:min(start+500, len(ids))])); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func reportRegistrationQuery(db *gorm.DB) (*gorm.DB, error) {
|
|
q := db.Table("tab_user")
|
|
if db.Dialector.Name() != "mysql" || !db.Migrator().HasIndex("tab_user", "idx_sw_user_registration") {
|
|
return q, nil
|
|
}
|
|
// 跨平台任务也必须提供组合索引的首列,避免又回到数百万主账号扫描。
|
|
// 平台值取同一事务中的实际数据,不写死平台名单,也保留历史 NULL。
|
|
var rows []struct {
|
|
SourceDB *string `gorm:"column:source_db"`
|
|
}
|
|
if err := db.Table("tab_user").Distinct("source_db").Scan(&rows).Error; err != nil {
|
|
return nil, business.ErrStorage
|
|
}
|
|
sources := []string{}
|
|
hasNull := false
|
|
for _, row := range rows {
|
|
if row.SourceDB == nil {
|
|
hasNull = true
|
|
} else {
|
|
sources = append(sources, *row.SourceDB)
|
|
}
|
|
}
|
|
if len(sources) == 0 {
|
|
if hasNull {
|
|
return q.Where("source_db IS NULL"), nil
|
|
}
|
|
return q.Where("1=0"), nil
|
|
}
|
|
if hasNull {
|
|
return q.Where("(source_db IN ? OR source_db IS NULL)", sources), nil
|
|
}
|
|
return q.Where("source_db IN ?", sources), nil
|
|
}
|