feat: 迁移渠道商务游戏排行榜及同期比较

This commit is contained in:
Yvan
2026-11-04 01:34:37 +08:00
parent cbf0ee9be3
commit ac4e13e610
27 changed files with 1079 additions and 6 deletions
+1 -1
View File
@@ -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)
+235
View File
@@ -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
}
+76
View File
@@ -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("持平环比错误")
}
}