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("持平环比错误")
}
}
+201
View File
@@ -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
}
+71
View File
@@ -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("缺表应明确未接入")
}
}
+1
View File
@@ -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)),
+1
View File
@@ -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},
+2 -2
View File
@@ -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))
+3
View File
@@ -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: "查询渠道充值最高与最低排行"},
+2 -1
View File
@@ -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)
}
}
+1
View File
@@ -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,
+29
View File
@@ -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
View File
@@ -1,6 +1,7 @@
package handler
type Set struct {
Ranking *Ranking
Workbench *Workbench
ServerAnalysis *ServerAnalysis
Analytics *Analytics
+5
View File
@@ -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)
+40
View File
@@ -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
}
+27
View File
@@ -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"`
}
+1
View File
@@ -27,6 +27,7 @@ var ProviderSet = wire.NewSet(
businessservice.NewAnalyticsService,
businessservice.NewServerAnalysisService,
businessservice.NewWorkbenchService,
businessservice.NewRankingService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,