feat: 迁移渠道商务游戏排行榜及同期比较
This commit is contained in:
+1
-1
@@ -11,4 +11,4 @@ import (
|
||||
)
|
||||
|
||||
// ProviderSet is biz providers.
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase)
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase)
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RankingKind string
|
||||
|
||||
const (
|
||||
ChannelRanking RankingKind = "channel"
|
||||
BusinessRanking RankingKind = "business"
|
||||
GameRanking RankingKind = "game"
|
||||
)
|
||||
|
||||
type RankingRange struct {
|
||||
AnalyticsFilter
|
||||
PreviousBegin, PreviousEnd string
|
||||
}
|
||||
type RankingMetrics struct {
|
||||
Counts PortraitCounts
|
||||
Pay, Name string
|
||||
}
|
||||
type RankingBusiness struct {
|
||||
ID uint
|
||||
Name string
|
||||
Level int
|
||||
Channels []AnalyticsKey
|
||||
}
|
||||
type RankingData struct {
|
||||
Current, Previous map[AnalyticsKey]*RankingMetrics
|
||||
Businesses []*RankingBusiness
|
||||
Available bool
|
||||
}
|
||||
type RankingItem struct {
|
||||
AnalyticsKey
|
||||
ObjectID, Name, Value string
|
||||
Rank int
|
||||
MoM *string
|
||||
MoMStatus *int
|
||||
}
|
||||
type RankingPage struct {
|
||||
Register, Recharge, PayRate, Active []*RankingItem
|
||||
Begin, End string
|
||||
Available bool
|
||||
}
|
||||
type RankingRepo interface {
|
||||
Collect(context.Context, RankingKind, AnalyticsScope, RankingRange) (*RankingData, error)
|
||||
}
|
||||
type RankingUsecase struct {
|
||||
repo RankingRepo
|
||||
accounts *AccountUsecase
|
||||
preferences *UserPreferenceUsecase
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewRankingUsecase(repo RankingRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *RankingUsecase {
|
||||
return &RankingUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
|
||||
}
|
||||
func (uc *RankingUsecase) List(ctx context.Context, kind RankingKind, f AnalyticsFilter) (*RankingPage, error) {
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind != ChannelRanking && kind != BusinessRanking && kind != GameRanking {
|
||||
return nil, ErrAnalyticsInput
|
||||
}
|
||||
if f.StartDate == nil || f.EndDate == nil {
|
||||
day := workbenchWindow(uc.now()).Today
|
||||
f.StartDate, f.EndDate = &day, &day
|
||||
}
|
||||
if f.EndDate.Before(*f.StartDate) {
|
||||
return nil, ErrAnalyticsDateOrder
|
||||
}
|
||||
f.Begin, f.End = f.StartDate.Format("2006-01-02"), f.EndDate.Format("2006-01-02")
|
||||
out := &RankingPage{Register: []*RankingItem{}, Recharge: []*RankingItem{}, PayRate: []*RankingItem{}, Active: []*RankingItem{}, Begin: f.Begin, End: f.End, Available: true}
|
||||
if kind == BusinessRanking && actor.AuthorityID != 888 && account.Level >= 3 {
|
||||
return out, nil
|
||||
}
|
||||
pref, err := uc.preferences.Current(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
viewer := portraitUserScope(account)
|
||||
viewer.All = actor.AuthorityID == 888
|
||||
days := int((f.EndDate.Unix()-f.StartDate.Unix())/86400) + 1
|
||||
rangeFilter := RankingRange{AnalyticsFilter: f, PreviousBegin: f.StartDate.AddDate(0, 0, -days).Format("2006-01-02"), PreviousEnd: f.StartDate.AddDate(0, 0, -1).Format("2006-01-02")}
|
||||
data, err := uc.repo.Collect(ctx, kind, AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}, rangeFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Available = data.Available
|
||||
if !data.Available {
|
||||
return out, nil
|
||||
}
|
||||
for _, period := range []map[AnalyticsKey]*RankingMetrics{data.Current, data.Previous} {
|
||||
for _, row := range period {
|
||||
row.Counts = analyticsCap(row.Counts)
|
||||
}
|
||||
}
|
||||
if kind == BusinessRanking {
|
||||
data.Current, err = aggregateRankingBusinesses(data.Businesses, data.Current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.Previous, err = aggregateRankingBusinesses(data.Businesses, data.Previous)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, metric := range []struct {
|
||||
name string
|
||||
target *[]*RankingItem
|
||||
}{{"register", &out.Register}, {"pay", &out.Recharge}, {"rate", &out.PayRate}, {"active", &out.Active}} {
|
||||
rows, err := buildRankingItems(kind, metric.name, data.Current, data.Previous)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
*metric.target = rows
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func aggregateRankingBusinesses(businesses []*RankingBusiness, channels map[AnalyticsKey]*RankingMetrics) (map[AnalyticsKey]*RankingMetrics, error) {
|
||||
out := map[AnalyticsKey]*RankingMetrics{}
|
||||
for _, target := range businesses {
|
||||
if len(target.Channels) == 0 {
|
||||
continue
|
||||
}
|
||||
row := &RankingMetrics{Name: fmt.Sprintf("%s (L%d)", target.Name, target.Level)}
|
||||
pay := new(big.Rat)
|
||||
for _, key := range target.Channels {
|
||||
if c := channels[key]; c != nil {
|
||||
row.Counts = analyticsAdd(row.Counts, c.Counts)
|
||||
n, err := portraitNumber(c.Pay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pay.Add(pay, n)
|
||||
}
|
||||
}
|
||||
// 商务人数沿用各渠道人数相加;使用 int64 避免原 Java int 溢出,不跨渠道重新去重。
|
||||
row.Pay = pay.RatString()
|
||||
out[AnalyticsKey{ID: int64(target.ID)}] = row
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func rankingValue(row *RankingMetrics, metric string) (*big.Rat, bool, error) {
|
||||
if row == nil {
|
||||
return new(big.Rat), false, nil
|
||||
}
|
||||
switch metric {
|
||||
case "register":
|
||||
return big.NewRat(row.Counts.Register, 1), row.Counts.Register > 0, nil
|
||||
case "active":
|
||||
return big.NewRat(row.Counts.Active, 1), row.Counts.Active > 0, nil
|
||||
case "rate":
|
||||
value := analyticsRatio(big.NewRat(row.Counts.Pay, 1), row.Counts.Active, true)
|
||||
n, _ := portraitNumber(value)
|
||||
return n, row.Counts.Active > 0, nil
|
||||
default:
|
||||
n, err := portraitNumber(row.Pay)
|
||||
return n, err == nil && n.Sign() > 0, err
|
||||
}
|
||||
}
|
||||
func buildRankingItems(kind RankingKind, metric string, current, previous map[AnalyticsKey]*RankingMetrics) ([]*RankingItem, error) {
|
||||
rows := []*RankingItem{}
|
||||
values := map[*RankingItem]*big.Rat{}
|
||||
for key, row := range current {
|
||||
value, keep, err := rankingValue(row, metric)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !keep {
|
||||
continue
|
||||
}
|
||||
item := &RankingItem{AnalyticsKey: key, ObjectID: strconv.FormatInt(key.ID, 10), Name: row.Name}
|
||||
if kind == GameRanking {
|
||||
item.ObjectID = key.SourceDB + "|" + item.ObjectID
|
||||
if strings.TrimSpace(item.Name) == "" {
|
||||
item.Name = "游戏" + strconv.FormatInt(key.ID, 10)
|
||||
}
|
||||
}
|
||||
if kind == ChannelRanking {
|
||||
item.Name = (&LedgerNames{PromoteID: &key.ID, PromoteAccount: row.Name}).DisplayPromote()
|
||||
}
|
||||
digits := 0
|
||||
if metric == "pay" || metric == "rate" {
|
||||
digits = 2
|
||||
}
|
||||
item.Value = portraitDecimal(value, digits)
|
||||
values[item], _ = portraitNumber(item.Value)
|
||||
prev := previous[key]
|
||||
if prev != nil {
|
||||
prior, valid, err := rankingValue(prev, metric)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if metric == "rate" || valid {
|
||||
change := new(big.Rat).Sub(value, prior)
|
||||
if metric != "rate" {
|
||||
change.Mul(change, big.NewRat(100, 1))
|
||||
change.Quo(change, prior)
|
||||
}
|
||||
item.MoM = stringPointer(portraitDecimal(change, 2))
|
||||
rounded, _ := portraitNumber(*item.MoM)
|
||||
status := 0
|
||||
if new(big.Rat).Abs(rounded).Cmp(big.NewRat(1, 100)) >= 0 {
|
||||
status = rounded.Sign()
|
||||
}
|
||||
item.MoMStatus = &status
|
||||
}
|
||||
}
|
||||
rows = append(rows, item)
|
||||
}
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
a, b := rows[i], rows[j]
|
||||
if c := values[a].Cmp(values[b]); c != 0 {
|
||||
return c > 0
|
||||
}
|
||||
if a.SourceDB != b.SourceDB {
|
||||
return a.SourceDB < b.SourceDB
|
||||
}
|
||||
return a.ID < b.ID
|
||||
})
|
||||
rows = rows[:min(20, len(rows))]
|
||||
for i, row := range rows {
|
||||
row.Rank = i + 1
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rankingRepoFake struct {
|
||||
data *RankingData
|
||||
filter RankingRange
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *rankingRepoFake) Collect(_ context.Context, _ RankingKind, _ AnalyticsScope, f RankingRange) (*RankingData, error) {
|
||||
r.filter = f
|
||||
r.calls++
|
||||
out := &RankingData{Available: r.data.Available, Businesses: r.data.Businesses, Current: map[AnalyticsKey]*RankingMetrics{}, Previous: map[AnalyticsKey]*RankingMetrics{}}
|
||||
for k, v := range r.data.Current {
|
||||
c := *v
|
||||
out.Current[k] = &c
|
||||
}
|
||||
for k, v := range r.data.Previous {
|
||||
c := *v
|
||||
out.Previous[k] = &c
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func TestRankingComparisonsAndBusinessCountSemantics(t *testing.T) {
|
||||
k := AnalyticsKey{SourceDB: "lx", ID: 1}
|
||||
zero := AnalyticsKey{SourceDB: "lx", ID: 2}
|
||||
r := &rankingRepoFake{data: &RankingData{Available: true, Current: map[AnalyticsKey]*RankingMetrics{k: {Counts: PortraitCounts{Register: 3, Active: 4, Pay: 2}, Pay: "20.005"}, zero: {Counts: PortraitCounts{Active: 1}}}, Previous: map[AnalyticsKey]*RankingMetrics{k: {Counts: PortraitCounts{Register: 2, Active: 2, Pay: 1}, Pay: "10"}}}}
|
||||
a := &accountRepoFake{accounts: map[uint]*Account{7: {ID: 7, Level: 1, IDPath: "0"}, 9: {ID: 9, Level: 3, IDPath: "0,7"}}}
|
||||
uc := NewRankingUsecase(r, NewAccountUsecase(a), NewUserPreferenceUsecase(&channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}))
|
||||
uc.now = func() time.Time { return time.Date(2026, 11, 4, 9, 0, 0, 0, portraitLocation) }
|
||||
begin, end := time.Date(2026, 10, 29, 0, 0, 0, 0, portraitLocation), time.Date(2026, 11, 4, 0, 0, 0, 0, portraitLocation)
|
||||
p, err := uc.List(accountContext(7, 901), GameRanking, AnalyticsFilter{StartDate: &begin, EndDate: &end})
|
||||
if err != nil || len(p.Register) != 1 || p.Register[0].ObjectID != "lx|1" || p.Register[0].Name != "游戏1" || *p.Register[0].MoM != "50.00" || p.Recharge[0].Value != "20.01" || *p.Recharge[0].MoM != "100.05" || len(p.PayRate) != 2 || p.PayRate[1].Value != "0.00" || p.PayRate[1].MoM != nil {
|
||||
t.Fatalf("排行筛选、命名或环比错误: %+v %v", p, err)
|
||||
}
|
||||
if *p.PayRate[0].MoM != "0.00" || *p.PayRate[0].MoMStatus != 0 || r.filter.PreviousBegin != "2026-10-22" || r.filter.PreviousEnd != "2026-10-28" {
|
||||
t.Fatal("等长上期或百分点差错误")
|
||||
}
|
||||
r.data.Businesses = []*RankingBusiness{{ID: 8, Name: "二级商务", Level: 2, Channels: []AnalyticsKey{k, zero}}}
|
||||
p, err = uc.List(accountContext(7, 901), BusinessRanking, AnalyticsFilter{})
|
||||
if err != nil || p.Active[0].Value != "5" || p.PayRate[0].Value != "40.00" || p.Register[0].Name != "二级商务 (L2)" || p.Register[0].SourceDB != "" {
|
||||
t.Fatal("商务需加总各渠道人数,不全局去重")
|
||||
}
|
||||
calls := r.calls
|
||||
p, err = uc.List(accountContext(9, 903), BusinessRanking, AnalyticsFilter{})
|
||||
if err != nil || len(p.Register) != 0 || r.calls != calls {
|
||||
t.Fatal("三级商务不得展开商务榜查询")
|
||||
}
|
||||
r.data.Available = false
|
||||
p, err = uc.List(accountContext(7, 901), ChannelRanking, AnalyticsFilter{})
|
||||
if err != nil || p.Available || len(p.Active) != 0 {
|
||||
t.Fatal("缺数据不能产生伪造排行")
|
||||
}
|
||||
}
|
||||
func TestRankingTopTwentyAndStatusThreshold(t *testing.T) {
|
||||
current, previous := map[AnalyticsKey]*RankingMetrics{}, map[AnalyticsKey]*RankingMetrics{}
|
||||
for i := int64(1); i <= 25; i++ {
|
||||
k := AnalyticsKey{SourceDB: "lx", ID: i}
|
||||
current[k] = &RankingMetrics{Counts: PortraitCounts{Active: i}, Pay: "9007199254740992.99"}
|
||||
previous[k] = &RankingMetrics{Counts: PortraitCounts{Active: i}}
|
||||
}
|
||||
current[AnalyticsKey{SourceDB: "lx", ID: 25}].Pay = "9007199254740993.00"
|
||||
rows, err := buildRankingItems(ChannelRanking, "pay", current, previous)
|
||||
if err != nil || len(rows) != 20 || rows[0].ID != 25 || rows[19].Rank != 20 {
|
||||
t.Fatal("TOP20 或精确金额排序错误")
|
||||
}
|
||||
rows, err = buildRankingItems(ChannelRanking, "active", current, previous)
|
||||
if err != nil || *rows[0].MoMStatus != 0 || *rows[0].MoM != "0.00" {
|
||||
t.Fatal("持平环比错误")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
type rankingRepo struct{ data Provider }
|
||||
|
||||
func NewRankingRepo(data Provider) business.RankingRepo { return &rankingRepo{data: data} }
|
||||
|
||||
// 通过一次关系查询展开各上榜商务的下级绑定,避免旧服务对每个商务查询下级。
|
||||
func rankingBusinessBindings(db *gorm.DB, scope business.AnalyticsScope, sources []string) *gorm.DB {
|
||||
q := db.Table("sys_users target").Where("target.deleted_at IS NULL")
|
||||
if scope.Viewer.All {
|
||||
q = q.Where("target.level IN (1,2,3)")
|
||||
} else {
|
||||
q = q.Where("target.parent_id=?", scope.Viewer.SelfID)
|
||||
}
|
||||
prefix := "(COALESCE(NULLIF(target.id_path,''),'0') || ',' || target.id)"
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
prefix = "CONCAT(COALESCE(NULLIF(target.id_path,''),'0'),',',target.id)"
|
||||
}
|
||||
like := prefix + " || ',%'"
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
like = "CONCAT(" + prefix + ",',%')"
|
||||
}
|
||||
q = q.Joins("JOIN sys_users child ON child.deleted_at IS NULL AND (child.id=target.id OR (target.level<3 AND (child.id_path=" + prefix + " OR child.id_path LIKE (" + like + "))))").Joins("JOIN sys_user_bind_platform b ON b.user_id=child.id AND b.status='0'")
|
||||
if len(sources) > 0 {
|
||||
q = q.Where("b.source_db IN ?", sources)
|
||||
}
|
||||
return q
|
||||
}
|
||||
func rankingBusinessPromotes(db *gorm.DB, scope business.AnalyticsScope, sources []string) *gorm.DB {
|
||||
q := rankingBusinessBindings(db, scope, sources).Joins("JOIN tab_promote p ON p.source_db=b.source_db AND p.busier_id=b.platform_busier_id")
|
||||
if !scope.IncludeSub {
|
||||
q = q.Where("p.promote_level=1")
|
||||
}
|
||||
return q
|
||||
}
|
||||
func rankingReportQuery(db *gorm.DB, kind business.RankingKind, scope business.AnalyticsScope, f business.AnalyticsFilter, table string, mode int) *gorm.DB {
|
||||
if kind != business.BusinessRanking {
|
||||
return analyticsQuery(db, scope, f, table, mode)
|
||||
}
|
||||
all := business.AnalyticsScope{Viewer: business.UserScope{All: true}, IncludeSub: true}
|
||||
return analyticsQuery(db, all, f, table, mode).Where("EXISTS (?)", rankingBusinessPromotes(db, scope, f.Sources).Select("1").Where("p.source_db=r.source_db AND p.id=r.promote_id"))
|
||||
}
|
||||
func (r *rankingRepo) Collect(ctx context.Context, kind business.RankingKind, scope business.AnalyticsScope, f business.RankingRange) (*business.RankingData, error) {
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
out := &business.RankingData{Current: map[business.AnalyticsKey]*business.RankingMetrics{}, Previous: map[business.AnalyticsKey]*business.RankingMetrics{}, Available: true}
|
||||
if kind == business.BusinessRanking {
|
||||
var found []struct{ ID uint }
|
||||
if err := rankingBusinessBindings(db, scope, f.Sources).Select("target.id").Limit(1).Scan(&found).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
available, err := businessTableAvailable(db, "tab_promote")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
out.Available = false
|
||||
return out, nil
|
||||
}
|
||||
var pairs []struct {
|
||||
UserID uint
|
||||
Username string
|
||||
Level int
|
||||
SourceDB string `gorm:"column:source_db"`
|
||||
PromoteID int64
|
||||
}
|
||||
if err := rankingBusinessPromotes(db, scope, f.Sources).Select("DISTINCT target.id AS user_id,target.username,target.level,p.source_db,p.id AS promote_id").Scan(&pairs).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
businesses := map[uint]*business.RankingBusiness{}
|
||||
for _, po := range pairs {
|
||||
if businesses[po.UserID] == nil {
|
||||
businesses[po.UserID] = &business.RankingBusiness{ID: po.UserID, Name: po.Username, Level: po.Level}
|
||||
}
|
||||
target := businesses[po.UserID]
|
||||
target.Channels = append(target.Channels, business.AnalyticsKey{SourceDB: po.SourceDB, ID: po.PromoteID})
|
||||
}
|
||||
for _, target := range businesses {
|
||||
out.Businesses = append(out.Businesses, target)
|
||||
}
|
||||
if len(out.Businesses) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
} else {
|
||||
empty, available, err := prepareAnalytics(db, scope, f.AnalyticsFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Available = available
|
||||
if empty || !available {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"tab_sw_report_day", "tab_sw_report_day_key"} {
|
||||
available, err := businessTableAvailable(db, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
out.Available = false
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
for _, period := range []struct {
|
||||
begin, end string
|
||||
target map[business.AnalyticsKey]*business.RankingMetrics
|
||||
}{{f.Begin, f.End, out.Current}, {f.PreviousBegin, f.PreviousEnd, out.Previous}} {
|
||||
query := f.AnalyticsFilter
|
||||
query.Begin, query.End = period.begin, period.end
|
||||
rowFor := func(po analyticsAggPO) *business.RankingMetrics {
|
||||
key := business.AnalyticsKey{SourceDB: po.SourceDB, ID: *po.ID}
|
||||
if period.target[key] == nil {
|
||||
period.target[key] = &business.RankingMetrics{}
|
||||
}
|
||||
return period.target[key]
|
||||
}
|
||||
for _, pay := range []bool{true, false} {
|
||||
table, mode := "tab_sw_report_day", 0
|
||||
if !pay {
|
||||
table = "tab_sw_report_day_key"
|
||||
if scope.IDCardDedup {
|
||||
mode = 1
|
||||
}
|
||||
}
|
||||
q := rankingReportQuery(db, kind, scope, query, table, mode)
|
||||
id := "r.promote_id"
|
||||
if kind == business.GameRanking {
|
||||
id = "r.game_id"
|
||||
}
|
||||
if kind == business.ChannelRanking {
|
||||
q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id")
|
||||
id = "COALESCE(NULLIF(root_p.top_promote_id,0),r.promote_id)"
|
||||
}
|
||||
selects := "r.source_db," + id + " AS entity_id"
|
||||
group := "r.source_db," + id
|
||||
if pay {
|
||||
selects += ",COALESCE(SUM(r.total_pay),0) AS amount"
|
||||
} else {
|
||||
selects += ",r.metric,COUNT(DISTINCT r.identity_key) AS count"
|
||||
group += ",r.metric"
|
||||
}
|
||||
var rows []analyticsAggPO
|
||||
if err := q.Select(selects).Group(group).Scan(&rows).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
for _, po := range rows {
|
||||
if po.ID == nil || (pay && *po.ID < 0) {
|
||||
continue
|
||||
}
|
||||
row := rowFor(po)
|
||||
if pay {
|
||||
row.Pay = po.Amount
|
||||
} else {
|
||||
putAnalyticsCount(&row.Counts, po.Metric, po.Count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind != business.BusinessRanking && len(out.Current) > 0 {
|
||||
table, column := "tab_promote", "account"
|
||||
if kind == business.GameRanking {
|
||||
table, column = "tab_game", "game_name"
|
||||
}
|
||||
available, err := businessTableAvailable(db, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
out.Available = false
|
||||
return out, nil
|
||||
}
|
||||
keys := make([]business.AnalyticsKey, 0, len(out.Current))
|
||||
for key := range out.Current {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
for start := 0; start < len(keys); start += 300 {
|
||||
var rows []struct {
|
||||
ID int64
|
||||
SourceDB string `gorm:"column:source_db"`
|
||||
Name string
|
||||
}
|
||||
if err := db.Table(table).Select("id,source_db,"+column+" AS name").Where("(source_db,id) IN ?", analyticsPairs(keys[start:min(start+300, len(keys))])).Scan(&rows).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
for _, po := range rows {
|
||||
if row := out.Current[business.AnalyticsKey{SourceDB: po.SourceDB, ID: po.ID}]; row != nil {
|
||||
row.Name = po.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/business"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRankingStorageDifferentRootAndBusinessScopes(t *testing.T) {
|
||||
db, _, scope, filter := prepareAnalyticsData(t)
|
||||
r := NewRankingRepo(testData{db})
|
||||
ctx := context.Background()
|
||||
f := business.RankingRange{AnalyticsFilter: filter, PreviousBegin: "2026-10-01", PreviousEnd: "2026-10-03"}
|
||||
data, err := r.Collect(ctx, business.GameRanking, scope, f)
|
||||
if err != nil || len(data.Current) != 3 || data.Current[business.AnalyticsKey{SourceDB: "lx", ID: 1}].Name != "游戏甲" {
|
||||
t.Fatalf("游戏排行或当前名称错误: %+v %v", data, err)
|
||||
}
|
||||
scope.Viewer = business.UserScope{SelfID: 7, IncludeSelf: true, DescendantPath: "0,7"}
|
||||
data, err = r.Collect(ctx, business.BusinessRanking, scope, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var child *business.RankingBusiness
|
||||
for _, target := range data.Businesses {
|
||||
if target.ID == 8 {
|
||||
child = target
|
||||
}
|
||||
if target.ID != 8 && target.ID != 10 {
|
||||
t.Fatal("一级只能排行直属下级")
|
||||
}
|
||||
}
|
||||
if child == nil || len(child.Channels) != 5 {
|
||||
t.Fatalf("二级本人的 hw 和下属三级的 lx 渠道应合并: %+v", child)
|
||||
}
|
||||
if _, ok := data.Current[business.AnalyticsKey{SourceDB: "lx", ID: 0}]; ok {
|
||||
t.Fatal("商务榜渠道映射不应包含官方流水")
|
||||
}
|
||||
scope.Viewer = business.UserScope{SelfID: 8, IncludeSelf: true, DescendantPath: "0,7,8"}
|
||||
data, err = r.Collect(ctx, business.BusinessRanking, scope, f)
|
||||
if err != nil || len(data.Businesses) != 1 || data.Businesses[0].ID != 9 || len(data.Businesses[0].Channels) != 4 {
|
||||
t.Fatal("二级商务榜只能展示直属三级及其渠道")
|
||||
}
|
||||
scope.Viewer = business.UserScope{All: true}
|
||||
data, err = r.Collect(ctx, business.BusinessRanking, scope, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[uint]bool{}
|
||||
for _, b := range data.Businesses {
|
||||
seen[b.ID] = true
|
||||
}
|
||||
if !seen[7] || !seen[8] || !seen[9] || seen[1] || seen[99] {
|
||||
t.Fatal("超管应展示所选平台有渠道的各级商务,不能包含普通框架管理员")
|
||||
}
|
||||
if err := db.Exec("UPDATE tab_promote SET top_promote_id=777 WHERE source_db='lx' AND id=100").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope = business.AnalyticsScope{Viewer: channelTestScope(), IncludeSub: false, IDCardDedup: true}
|
||||
data, err = r.Collect(ctx, business.ChannelRanking, scope, f)
|
||||
root := data.Current[business.AnalyticsKey{SourceDB: "lx", ID: 777}]
|
||||
if err != nil || root == nil || portraitTestMoney(root.Pay) != "10.10" || root.Counts.Register != 1 {
|
||||
t.Fatalf("独立渠道榜使用 COALESCE(top,id),不应误用工作台 CASE 根算法: %+v %v", root, err)
|
||||
}
|
||||
if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err = r.Collect(ctx, business.ChannelRanking, scope, f)
|
||||
if err != nil || data.Available {
|
||||
t.Fatal("缺表应明确未接入")
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ var ProviderSet = wire.NewSet(
|
||||
databusiness.NewAnalyticsRepo,
|
||||
databusiness.NewServerAnalysisRepo,
|
||||
databusiness.NewWorkbenchRepo,
|
||||
databusiness.NewRankingRepo,
|
||||
NewIntegrationRuntime,
|
||||
wire.Bind(new(datasystem.Provider), new(*Data)),
|
||||
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
func Definition() module.Definition {
|
||||
return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{
|
||||
Menus: []module.Menu{
|
||||
{Name: "sw_RankingAnalysis", Path: "RankingAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/RankingAnalysis.vue", Title: "排行榜", Icon: "star", Sort: 6},
|
||||
{Name: "sw_ServerAnalysis", Path: "ServerAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/ServerAnalysis.vue", Title: "区服分析", Icon: "data-analysis", Sort: 5},
|
||||
{Name: "sw_ChannelDataStatistics", Path: "ChannelDataStatistics", ParentName: "sw_DataAnalysis", Component: "view/sw/ChannelDataStatistics.vue", Title: "渠道数据统计", Icon: "data-analysis", Sort: 1},
|
||||
{Name: "sw_GameAnalytics", Path: "GameAnalytics", ParentName: "sw_DataAnalysis", Component: "view/sw/GameAnalytics.vue", Title: "游戏数据", Icon: "data-analysis", Sort: 4},
|
||||
|
||||
@@ -33,8 +33,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
||||
menus += len(definition.Surface.Menus)
|
||||
apis += len(definition.Surface.APIs)
|
||||
}
|
||||
if menus != 17 || apis != 66 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 17/66", menus, apis)
|
||||
if menus != 18 || apis != 69 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 18/69", menus, apis)
|
||||
}
|
||||
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
||||
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
||||
|
||||
@@ -41,6 +41,9 @@ type routeValue struct {
|
||||
// Gin router files still bind handlers; tests keep those registrations aligned
|
||||
// with this catalog while public, audit and API metadata live only here.
|
||||
var routes = map[string]routeValue{
|
||||
"GET /sw/rank/channel": {group: "排行榜", description: "查询渠道四项排行与环比", audit: true},
|
||||
"GET /sw/rank/business": {group: "排行榜", description: "查询商务四项排行与环比", audit: true},
|
||||
"GET /sw/rank/game": {group: "排行榜", description: "查询游戏四项排行与环比", audit: true},
|
||||
"GET /sw/workbench/summary": {group: "工作台", description: "查询商务六项指标与同期对照"},
|
||||
"GET /sw/workbench/registerTop": {group: "工作台", description: "查询渠道注册排行"},
|
||||
"GET /sw/workbench/payRank": {group: "工作台", description: "查询渠道充值最高与最低排行"},
|
||||
|
||||
@@ -29,6 +29,7 @@ func emptyHandlers() *handler.Set {
|
||||
Analytics: &handler.Analytics{},
|
||||
ServerAnalysis: &handler.ServerAnalysis{},
|
||||
Workbench: &handler.Workbench{},
|
||||
Ranking: &handler.Ranking{},
|
||||
Ledger: &handler.Ledger{},
|
||||
ChannelPortrait: &handler.ChannelPortrait{},
|
||||
BusinessPortrait: &handler.BusinessPortrait{},
|
||||
@@ -109,7 +110,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
||||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
||||
t.Fatalf("registered route log count = %d, want %d", got, want)
|
||||
}
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":249`) {
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":252`) {
|
||||
t.Fatalf("startup route summary is missing: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewAnalytics,
|
||||
NewServerAnalysis,
|
||||
NewWorkbench,
|
||||
NewRanking,
|
||||
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
|
||||
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
|
||||
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
businessbiz "kra/internal/biz/business"
|
||||
"kra/internal/service/business"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type Ranking struct{ service *business.RankingService }
|
||||
|
||||
func NewRanking(service *business.RankingService) *Ranking { return &Ranking{service: service} }
|
||||
func (h *Ranking) list(c *gin.Context, kind businessbiz.RankingKind) {
|
||||
var req dto.RankingQuery
|
||||
if c.ShouldBindQuery(&req) != nil {
|
||||
Fail(c, "排行榜查询参数不正确")
|
||||
return
|
||||
}
|
||||
data, err := h.service.List(c.Request.Context(), kind, &req)
|
||||
if err != nil {
|
||||
Fail(c, errors.FromError(err).Message)
|
||||
return
|
||||
}
|
||||
OKWithData(c, data)
|
||||
}
|
||||
func (h *Ranking) Channel(c *gin.Context) { h.list(c, businessbiz.ChannelRanking) }
|
||||
func (h *Ranking) Business(c *gin.Context) { h.list(c, businessbiz.BusinessRanking) }
|
||||
func (h *Ranking) Game(c *gin.Context) { h.list(c, businessbiz.GameRanking) }
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
type Set struct {
|
||||
Ranking *Ranking
|
||||
Workbench *Workbench
|
||||
ServerAnalysis *ServerAnalysis
|
||||
Analytics *Analytics
|
||||
|
||||
@@ -20,6 +20,11 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
||||
return
|
||||
}
|
||||
registrations := []func(){
|
||||
func() {
|
||||
private.GET("/sw/rank/channel", r.handlers.Ranking.Channel)
|
||||
private.GET("/sw/rank/business", r.handlers.Ranking.Business)
|
||||
private.GET("/sw/rank/game", r.handlers.Ranking.Game)
|
||||
},
|
||||
func() {
|
||||
private.GET("/sw/workbench/summary", r.handlers.Workbench.Summary)
|
||||
private.GET("/sw/workbench/registerTop", r.handlers.Workbench.RegisterTop)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/business"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type RankingUsecase interface {
|
||||
List(context.Context, business.RankingKind, business.AnalyticsFilter) (*business.RankingPage, error)
|
||||
}
|
||||
type RankingService struct{ uc RankingUsecase }
|
||||
|
||||
func NewRankingService(uc *business.RankingUsecase) *RankingService { return &RankingService{uc: uc} }
|
||||
func rankingItems(rows []*business.RankingItem, kind business.RankingKind) []*dto.RankingItem {
|
||||
out := make([]*dto.RankingItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
item := &dto.RankingItem{Rank: row.Rank, ObjectID: row.ObjectID, ObjectName: row.Name, MetricValue: row.Value, MoM: row.MoM, MoMStatus: row.MoMStatus}
|
||||
if kind != business.BusinessRanking {
|
||||
source, label := row.SourceDB, business.PlatformName(row.SourceDB)
|
||||
item.SourceDB, item.SourceDBLabel = &source, &label
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (s *RankingService) List(ctx context.Context, kind business.RankingKind, req *dto.RankingQuery) (*dto.RankingPage, error) {
|
||||
if req == nil {
|
||||
return nil, business.ErrAnalyticsInput
|
||||
}
|
||||
f, err := analyticsRequest(&dto.AnalyticsQuery{Sources: req.Sources, BeginTime: req.BeginTime, EndTime: req.EndTime})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page, err := s.uc.List(ctx, kind, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.RankingPage{Register: rankingItems(page.Register, kind), Recharge: rankingItems(page.Recharge, kind), PayRate: rankingItems(page.PayRate, kind), Active: rankingItems(page.Active, kind), Begin: page.Begin, End: page.End, Available: page.Available}, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package dto
|
||||
|
||||
type RankingQuery struct {
|
||||
Sources []string `form:"sourceDb"`
|
||||
BeginTime string `form:"beginTime"`
|
||||
EndTime string `form:"endTime"`
|
||||
GameID *int64 `form:"gameId"`
|
||||
}
|
||||
type RankingItem struct {
|
||||
Rank int `json:"rank"`
|
||||
ObjectID string `json:"objectId"`
|
||||
ObjectName string `json:"objectName"`
|
||||
SourceDB *string `json:"sourceDb"`
|
||||
SourceDBLabel *string `json:"sourceDbLabel"`
|
||||
MetricValue string `json:"metricValue"`
|
||||
MoM *string `json:"momValue"`
|
||||
MoMStatus *int `json:"momStatus"`
|
||||
}
|
||||
type RankingPage struct {
|
||||
Register []*RankingItem `json:"registerRankList"`
|
||||
Recharge []*RankingItem `json:"rechargeRankList"`
|
||||
PayRate []*RankingItem `json:"payRateRankList"`
|
||||
Active []*RankingItem `json:"activeRankList"`
|
||||
Begin string `json:"beginTime"`
|
||||
End string `json:"endTime"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
@@ -27,6 +27,7 @@ var ProviderSet = wire.NewSet(
|
||||
businessservice.NewAnalyticsService,
|
||||
businessservice.NewServerAnalysisService,
|
||||
businessservice.NewWorkbenchService,
|
||||
businessservice.NewRankingService,
|
||||
systemservice.ProviderSet,
|
||||
paymentservice.ProviderSet,
|
||||
integrationservice.ProviderSet,
|
||||
|
||||
Reference in New Issue
Block a user