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)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrServerAnalysisGame = errors.BadRequest("SW_SERVER_ANALYSIS_GAME", "请选择游戏")
|
||||
ErrServerOptionsUnavailable = errors.BadRequest("SW_SERVER_OPTIONS_UNAVAILABLE", "游戏或区服数据尚未接入")
|
||||
)
|
||||
|
||||
type ServerAnalysisKey struct {
|
||||
SourceDB string
|
||||
GameID int64
|
||||
ServerNum string
|
||||
}
|
||||
type ServerAnalysisFilter struct {
|
||||
AnalyticsFilter
|
||||
ServerNum string
|
||||
}
|
||||
type ServerAnalysisRow struct {
|
||||
ServerAnalysisKey
|
||||
GameName, ServerName string
|
||||
OpenTime *int64
|
||||
Counts PortraitCounts
|
||||
Pay, ARPU string
|
||||
}
|
||||
type ServerAnalysisData struct {
|
||||
Rows []*ServerAnalysisRow
|
||||
Available bool
|
||||
}
|
||||
type ServerAnalysisPage struct {
|
||||
ServerAnalysisData
|
||||
Total int64
|
||||
Summary *ServerAnalysisRow
|
||||
Begin, End string
|
||||
}
|
||||
type ServerOption struct {
|
||||
Number, Name string
|
||||
OpenTime *int64
|
||||
}
|
||||
type ServerAnalysisRepo interface {
|
||||
Aggregate(context.Context, AnalyticsScope, ServerAnalysisFilter) (*ServerAnalysisData, error)
|
||||
Servers(context.Context, []string, []string, string, int) ([]*ServerOption, error)
|
||||
}
|
||||
type ServerAnalysisUsecase struct {
|
||||
repo ServerAnalysisRepo
|
||||
accounts *AccountUsecase
|
||||
preferences *UserPreferenceUsecase
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewServerAnalysisUsecase(repo ServerAnalysisRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *ServerAnalysisUsecase {
|
||||
return &ServerAnalysisUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
|
||||
}
|
||||
func (uc *ServerAnalysisUsecase) Servers(ctx context.Context, sources, games []string, keyword string, limit int) ([]*ServerOption, error) {
|
||||
if _, _, err := uc.accounts.actor(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(games) == 0 {
|
||||
return nil, ErrServerAnalysisGame
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100000
|
||||
}
|
||||
return uc.repo.Servers(ctx, sources, games, strings.TrimSpace(keyword), limit)
|
||||
}
|
||||
func (uc *ServerAnalysisUsecase) List(ctx context.Context, f ServerAnalysisFilter, page, size int) (*ServerAnalysisPage, error) {
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(f.GameNames) == 0 {
|
||||
return nil, ErrServerAnalysisGame
|
||||
}
|
||||
pref, err := uc.preferences.Current(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.StartDate == nil || f.EndDate == nil {
|
||||
now := uc.now().In(portraitLocation)
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, portraitLocation)
|
||||
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")
|
||||
f.ServerNum = strings.TrimSpace(f.ServerNum)
|
||||
viewer := portraitUserScope(account)
|
||||
viewer.All = actor.AuthorityID == 888
|
||||
data, err := uc.repo.Aggregate(ctx, AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &ServerAnalysisPage{ServerAnalysisData: ServerAnalysisData{Rows: []*ServerAnalysisRow{}, Available: data.Available}, Begin: f.Begin, End: f.End}
|
||||
if !data.Available {
|
||||
return out, nil
|
||||
}
|
||||
rows := make([]*ServerAnalysisRow, 0, len(data.Rows))
|
||||
summary := &ServerAnalysisRow{ServerName: "合计"}
|
||||
sumPay := new(big.Rat)
|
||||
for _, row := range data.Rows {
|
||||
if strings.TrimSpace(row.ServerNum) == "" {
|
||||
continue
|
||||
}
|
||||
row.Counts = analyticsCap(row.Counts)
|
||||
pay, err := portraitNumber(row.Pay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.Pay = portraitDecimal(pay, 2)
|
||||
pay, _ = portraitNumber(row.Pay)
|
||||
// 未指定区服时忽略全零行;指定区服仍只返回报表中已有的分组。
|
||||
if f.ServerNum == "" && row.Counts.Register == 0 && row.Counts.Active == 0 && row.Counts.Pay == 0 && pay.Sign() == 0 {
|
||||
continue
|
||||
}
|
||||
row.ARPU = analyticsRatio(pay, row.Counts.Active, false)
|
||||
summary.Counts = analyticsAdd(summary.Counts, row.Counts)
|
||||
sumPay.Add(sumPay, pay)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
summary.Pay = portraitDecimal(sumPay, 2)
|
||||
summary.ARPU = analyticsRatio(sumPay, summary.Counts.Active, false)
|
||||
out.Summary, out.Total = summary, int64(len(rows))
|
||||
if err := sortServerAnalysis(rows, f.SortField, f.Ascending); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 || size > 500 {
|
||||
size = 500
|
||||
}
|
||||
if page > len(rows)/size+1 {
|
||||
return out, nil
|
||||
}
|
||||
from := (page - 1) * size
|
||||
if from < len(rows) {
|
||||
out.Rows = rows[from:min(from+size, len(rows))]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func sortServerAnalysis(rows []*ServerAnalysisRow, field string, ascending bool) error {
|
||||
values := map[*ServerAnalysisRow]*big.Rat{}
|
||||
for _, row := range rows {
|
||||
var n *big.Rat
|
||||
var err error
|
||||
switch field {
|
||||
case "openTime":
|
||||
v := int64(0)
|
||||
if row.OpenTime != nil {
|
||||
v = *row.OpenTime
|
||||
}
|
||||
n = big.NewRat(v, 1)
|
||||
case "newRegisterCount":
|
||||
n = big.NewRat(row.Counts.Register, 1)
|
||||
case "activeCount":
|
||||
n = big.NewRat(row.Counts.Active, 1)
|
||||
case "payUserCount":
|
||||
n = big.NewRat(row.Counts.Pay, 1)
|
||||
case "arpu":
|
||||
n, err = portraitNumber(row.ARPU)
|
||||
default:
|
||||
n, err = portraitNumber(row.Pay)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values[row] = n
|
||||
}
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
a, b := rows[i], rows[j]
|
||||
if c := values[a].Cmp(values[b]); c != 0 {
|
||||
if ascending {
|
||||
return c < 0
|
||||
}
|
||||
return c > 0
|
||||
}
|
||||
if a.ServerNum != b.ServerNum {
|
||||
return strings.TrimSpace(a.ServerNum) < strings.TrimSpace(b.ServerNum)
|
||||
}
|
||||
if a.SourceDB != b.SourceDB {
|
||||
return a.SourceDB < b.SourceDB
|
||||
}
|
||||
return a.GameID < b.GameID
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type serverAnalysisFake struct {
|
||||
data *ServerAnalysisData
|
||||
scope AnalyticsScope
|
||||
filter ServerAnalysisFilter
|
||||
limit int
|
||||
}
|
||||
|
||||
func (r *serverAnalysisFake) Aggregate(_ context.Context, s AnalyticsScope, f ServerAnalysisFilter) (*ServerAnalysisData, error) {
|
||||
r.scope, r.filter = s, f
|
||||
out := &ServerAnalysisData{Available: r.data.Available}
|
||||
for _, row := range r.data.Rows {
|
||||
c := *row
|
||||
out.Rows = append(out.Rows, &c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (r *serverAnalysisFake) Servers(_ context.Context, _, _ []string, _ string, limit int) ([]*ServerOption, error) {
|
||||
r.limit = limit
|
||||
return []*ServerOption{}, nil
|
||||
}
|
||||
func TestServerAnalysisSummaryZerosSortingAndLimits(t *testing.T) {
|
||||
r := &serverAnalysisFake{data: &ServerAnalysisData{Available: true, Rows: []*ServerAnalysisRow{
|
||||
{ServerAnalysisKey: ServerAnalysisKey{SourceDB: "lx", GameID: 1, ServerNum: "02"}, Counts: PortraitCounts{Register: 2, Active: 3, Pay: 1}, Pay: "10.005"},
|
||||
{ServerAnalysisKey: ServerAnalysisKey{SourceDB: "hw", GameID: 1, ServerNum: "01"}, Counts: PortraitCounts{Register: 3, Active: 2, Pay: 1}, Pay: "20"},
|
||||
{ServerAnalysisKey: ServerAnalysisKey{SourceDB: "lx", GameID: 1, ServerNum: "zero"}, Pay: "0"},
|
||||
}}}
|
||||
a := &accountRepoFake{accounts: map[uint]*Account{7: {ID: 7, Level: 1, IDPath: "0"}}}
|
||||
uc := NewServerAnalysisUsecase(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) }
|
||||
f := ServerAnalysisFilter{AnalyticsFilter: AnalyticsFilter{GameNames: []string{"游戏"}}}
|
||||
p, err := uc.List(accountContext(7, 901), f, 1, 1)
|
||||
if err != nil || p.Total != 2 || len(p.Rows) != 1 || p.Rows[0].ServerNum != "01" || p.Summary.Pay != "30.01" || p.Summary.Counts.Register != 5 || p.Summary.ARPU != "6.00" || p.Begin != "2026-11-04" {
|
||||
t.Fatalf("汇总、四舍五入或分页错误: %+v %v", p, err)
|
||||
}
|
||||
if !r.scope.IncludeSub || !r.scope.IDCardDedup || r.scope.Viewer.All {
|
||||
t.Fatal("查看者偏好或范围未传递")
|
||||
}
|
||||
p, err = uc.List(accountContext(7, 901), f, 999999999, 10)
|
||||
if err != nil || len(p.Rows) != 0 || p.Summary.Pay != "30.01" {
|
||||
t.Fatal("越界分页不应丢失汇总")
|
||||
}
|
||||
r.data.Rows = r.data.Rows[2:]
|
||||
f.ServerNum = "zero"
|
||||
p, err = uc.List(accountContext(7, 901), f, 1, 0)
|
||||
if err != nil || p.Total != 1 {
|
||||
t.Fatal("指定区服时应保留已有全零行")
|
||||
}
|
||||
r.data.Available = false
|
||||
p, err = uc.List(accountContext(7, 901), f, 1, 10)
|
||||
if err != nil || p.Summary != nil || p.Available {
|
||||
t.Fatal("未接入不能返回零汇总")
|
||||
}
|
||||
if _, err := uc.List(accountContext(7, 901), ServerAnalysisFilter{}, 1, 10); err != ErrServerAnalysisGame {
|
||||
t.Fatal("区服查询必须选择游戏")
|
||||
}
|
||||
for _, sample := range []struct{ in, want int }{{0, 50}, {100, 100}, {101, 100000}} {
|
||||
if _, err := uc.Servers(accountContext(7, 901), nil, []string{"游戏"}, "", sample.in); err != nil || r.limit != sample.want {
|
||||
t.Fatal("区服选项上限与旧行为不符")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
type serverAnalysisRepo struct{ data Provider }
|
||||
|
||||
func NewServerAnalysisRepo(data Provider) business.ServerAnalysisRepo {
|
||||
return &serverAnalysisRepo{data: data}
|
||||
}
|
||||
|
||||
func serverReportQuery(db *gorm.DB, scope business.AnalyticsScope, f business.ServerAnalysisFilter, table string, mode int) *gorm.DB {
|
||||
q := ledgerScopeQuery(db, business.LedgerScope{Viewer: scope.Viewer, IncludeSub: scope.IncludeSub, Unrestricted: scope.Viewer.All}, business.LedgerFilter{Sources: f.Sources}, table)
|
||||
q = q.Where("r.stat_date >= ? AND r.stat_date <= ? AND r.dedup_mode=? AND r.server_num != ''", f.Begin, f.End, mode).Where("r.game_name IN ?", f.GameNames)
|
||||
if f.ServerNum != "" {
|
||||
q = q.Where("r.server_num=?", f.ServerNum)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
type serverAnalysisPO struct {
|
||||
SourceDB string `gorm:"column:source_db"`
|
||||
GameID int64
|
||||
ServerNum, GameName, ServerName string
|
||||
StartTime *int64
|
||||
Metric int
|
||||
Count int64
|
||||
Amount string
|
||||
}
|
||||
|
||||
func (r *serverAnalysisRepo) Aggregate(ctx context.Context, scope business.AnalyticsScope, f business.ServerAnalysisFilter) (*business.ServerAnalysisData, error) {
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
out := &business.ServerAnalysisData{Rows: []*business.ServerAnalysisRow{}, Available: true}
|
||||
empty, available, err := prepareAnalytics(db, scope, business.AnalyticsFilter{Sources: f.Sources})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Available = available
|
||||
if empty || !available {
|
||||
return out, nil
|
||||
}
|
||||
for _, table := range []string{"tab_sw_report_server_day", "tab_sw_report_server_day_key", "tab_game_server", "tab_game"} {
|
||||
available, err := businessTableAvailable(db, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
out.Available = false
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
rows := map[business.ServerAnalysisKey]*business.ServerAnalysisRow{}
|
||||
rowFor := func(po serverAnalysisPO) *business.ServerAnalysisRow {
|
||||
key := business.ServerAnalysisKey{SourceDB: po.SourceDB, GameID: po.GameID, ServerNum: po.ServerNum}
|
||||
if rows[key] == nil {
|
||||
rows[key] = &business.ServerAnalysisRow{ServerAnalysisKey: key, Pay: "0"}
|
||||
}
|
||||
return rows[key]
|
||||
}
|
||||
mode := 0
|
||||
if scope.IDCardDedup {
|
||||
mode = 1
|
||||
}
|
||||
var counts []serverAnalysisPO
|
||||
if err := serverReportQuery(db, scope, f, "tab_sw_report_server_day_key", mode).Select("r.source_db,r.game_id,r.server_num,r.metric,COUNT(DISTINCT r.identity_key) AS count").Group("r.source_db,r.game_id,r.server_num,r.metric").Scan(&counts).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
for _, po := range counts {
|
||||
if strings.TrimSpace(po.ServerNum) != "" {
|
||||
putAnalyticsCount(&rowFor(po).Counts, po.Metric, po.Count)
|
||||
}
|
||||
}
|
||||
var pays []serverAnalysisPO
|
||||
if err := serverReportQuery(db, scope, f, "tab_sw_report_server_day", 0).Select("r.source_db,r.game_id,r.server_num,MAX(r.game_name) AS game_name,COALESCE(SUM(r.total_pay),0) AS amount").Group("r.source_db,r.game_id,r.server_num").Scan(&pays).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
for _, po := range pays {
|
||||
if strings.TrimSpace(po.ServerNum) == "" {
|
||||
continue
|
||||
}
|
||||
row := rowFor(po)
|
||||
row.Pay = po.Amount
|
||||
row.GameName = strings.TrimSpace(po.GameName)
|
||||
}
|
||||
if err := fillServerAnalysisNames(db, rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
out.Rows = append(out.Rows, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func fillServerAnalysisNames(db *gorm.DB, rows map[business.ServerAnalysisKey]*business.ServerAnalysisRow) error {
|
||||
keys := make([][]any, 0, len(rows))
|
||||
missingGames := map[business.AnalyticsKey]bool{}
|
||||
for key, row := range rows {
|
||||
keys = append(keys, []any{key.SourceDB, key.GameID, key.ServerNum})
|
||||
if row.GameName == "" {
|
||||
missingGames[business.AnalyticsKey{SourceDB: key.SourceDB, ID: key.GameID}] = true
|
||||
}
|
||||
}
|
||||
// 三元组限定资料查询,避免同号游戏或同名区服跨平台串联。
|
||||
for start := 0; start < len(keys); start += 250 {
|
||||
var meta []serverAnalysisPO
|
||||
if err := db.Table("tab_game_server").Select("source_db,game_id,server_num,server_name,start_time").Where("(source_db,game_id,server_num) IN ?", keys[start:min(start+250, len(keys))]).Order("id").Scan(&meta).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
seen := map[business.ServerAnalysisKey]bool{}
|
||||
for _, po := range meta {
|
||||
key := business.ServerAnalysisKey{SourceDB: po.SourceDB, GameID: po.GameID, ServerNum: po.ServerNum}
|
||||
if row := rows[key]; row != nil && !seen[key] {
|
||||
row.ServerName, row.OpenTime = po.ServerName, po.StartTime
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs := make([]business.AnalyticsKey, 0, len(missingGames))
|
||||
for key := range missingGames {
|
||||
pairs = append(pairs, key)
|
||||
}
|
||||
names := map[business.AnalyticsKey]string{}
|
||||
for start := 0; start < len(pairs); start += 300 {
|
||||
var meta []struct {
|
||||
SourceDB string `gorm:"column:source_db"`
|
||||
ID int64
|
||||
GameName string
|
||||
}
|
||||
if err := db.Table("tab_game").Select("source_db,id,game_name").Where("(source_db,id) IN ?", analyticsPairs(pairs[start:min(start+300, len(pairs))])).Scan(&meta).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
for _, po := range meta {
|
||||
names[business.AnalyticsKey{SourceDB: po.SourceDB, ID: po.ID}] = po.GameName
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.GameName == "" {
|
||||
row.GameName = names[business.AnalyticsKey{SourceDB: row.SourceDB, ID: row.GameID}]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *serverAnalysisRepo) Servers(ctx context.Context, sources, games []string, keyword string, limit int) ([]*business.ServerOption, error) {
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
for _, table := range []string{"tab_game", "tab_game_server"} {
|
||||
available, err := businessTableAvailable(db, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
return nil, business.ErrServerOptionsUnavailable
|
||||
}
|
||||
}
|
||||
q := db.Table("tab_game_server s").Where("EXISTS (?)", db.Table("tab_game g").Select("1").Where("g.source_db=s.source_db AND g.id=s.game_id AND g.game_name IN ?", games))
|
||||
if len(sources) > 0 {
|
||||
q = q.Where("s.source_db IN ?", sources)
|
||||
}
|
||||
if keyword != "" {
|
||||
q = q.Where("s.server_name LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
var rows []serverAnalysisPO
|
||||
if err := q.Select("s.server_num,s.server_name,s.start_time").Order("s.server_num,s.source_db,s.game_id,s.id").Limit(limit).Scan(&rows).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
out := make([]*business.ServerOption, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, &business.ServerOption{Number: row.ServerNum, Name: row.ServerName, OpenTime: row.StartTime})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/business"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerAnalysisStorageScopeSnapshotAndPreference(t *testing.T) {
|
||||
db, _ := prepareChannels(t)
|
||||
for _, sql := range []string{
|
||||
"CREATE TABLE tab_game_server(id INTEGER,source_db TEXT,game_id INTEGER,server_num TEXT,server_name TEXT,start_time INTEGER)",
|
||||
"INSERT INTO tab_game_server VALUES(1,'lx',1,'001','龙翔一区',100),(2,'hw',1,'001','汇玩一区',200),(3,'lx',2,'001','游戏乙一区',300),(4,'yq',1,'001','无权区服',400)",
|
||||
"CREATE TABLE tab_sw_report_server_day(source_db TEXT,game_id INTEGER,game_name TEXT,promote_id INTEGER,server_num TEXT,stat_date DATE,dedup_mode INTEGER,total_pay DECIMAL(20,2))",
|
||||
"INSERT INTO tab_sw_report_server_day VALUES('lx',1,'历史游戏',100,'001','2026-11-04',0,10),('lx',1,'历史游戏',101,'001','2026-11-04',0,20),('hw',1,'历史游戏',100,'001','2026-11-04',0,40),('yq',1,'历史游戏',100,'001','2026-11-04',0,999),('lx',1,'历史游戏',100,'001','2026-11-04',1,9999),('lx',1,'历史游戏',0,'official','2026-11-04',0,5),('lx',1,'历史游戏',100,'','2026-11-04',0,9999)",
|
||||
"CREATE TABLE tab_sw_report_server_day_key(source_db TEXT,game_id INTEGER,game_name TEXT,promote_id INTEGER,server_num TEXT,stat_date DATE,dedup_mode INTEGER,metric INTEGER,identity_key TEXT)",
|
||||
"INSERT INTO tab_sw_report_server_day_key VALUES('lx',1,'历史游戏',100,'001','2026-11-04',1,1,'A'),('lx',1,'历史游戏',101,'001','2026-11-04',1,1,'A'),('lx',1,'历史游戏',100,'001','2026-11-04',0,1,'1'),('lx',1,'历史游戏',101,'001','2026-11-04',0,1,'2'),('hw',1,'历史游戏',100,'001','2026-11-04',1,1,'A'),('lx',2,'游戏乙',100,'001','2026-11-04',1,1,'B')",
|
||||
} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
r := NewServerAnalysisRepo(testData{db})
|
||||
ctx := context.Background()
|
||||
s := business.AnalyticsScope{Viewer: channelTestScope(), IncludeSub: true, IDCardDedup: true}
|
||||
f := business.ServerAnalysisFilter{AnalyticsFilter: business.AnalyticsFilter{Sources: []string{"lx", "hw", "yq"}, GameNames: []string{"历史游戏", "游戏乙"}, Begin: "2026-11-04", End: "2026-11-04"}}
|
||||
p, err := r.Aggregate(ctx, s, f)
|
||||
if err != nil || len(p.Rows) != 3 {
|
||||
t.Fatalf("区服范围错误: %+v %v", p, err)
|
||||
}
|
||||
for _, row := range p.Rows {
|
||||
switch {
|
||||
case row.SourceDB == "lx" && row.GameID == 1:
|
||||
if row.GameName != "历史游戏" || row.ServerName != "龙翔一区" || row.OpenTime == nil || *row.OpenTime != 100 || row.Counts.Register != 1 || portraitTestMoney(row.Pay) != "30.00" {
|
||||
t.Fatalf("去重、资料或金额错误: %+v", row)
|
||||
}
|
||||
case row.SourceDB == "hw":
|
||||
if row.ServerName != "汇玩一区" || portraitTestMoney(row.Pay) != "40.00" {
|
||||
t.Fatalf("跨平台同号串联: %+v", row)
|
||||
}
|
||||
case row.GameID == 2:
|
||||
if row.GameName != "游戏乙" || row.ServerName != "游戏乙一区" {
|
||||
t.Fatalf("只有人数的行应补当前游戏名: %+v", row)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("出现无权行: %+v", row)
|
||||
}
|
||||
}
|
||||
s.IDCardDedup = false
|
||||
f.Sources = []string{"lx"}
|
||||
f.GameNames = []string{"历史游戏"}
|
||||
p, err = r.Aggregate(ctx, s, f)
|
||||
if err != nil || len(p.Rows) != 1 || p.Rows[0].Counts.Register != 2 || portraitTestMoney(p.Rows[0].Pay) != "30.00" {
|
||||
t.Fatal("身份证偏好影响了金额或未切换人数")
|
||||
}
|
||||
s.IncludeSub = false
|
||||
p, err = r.Aggregate(ctx, s, f)
|
||||
if err != nil || p.Rows[0].Counts.Register != 1 || portraitTestMoney(p.Rows[0].Pay) != "10.00" {
|
||||
t.Fatal("下级开关未生效")
|
||||
}
|
||||
s = business.AnalyticsScope{Viewer: business.UserScope{All: true}, IncludeSub: true, IDCardDedup: true}
|
||||
p, err = r.Aggregate(ctx, s, f)
|
||||
if err != nil || len(p.Rows) != 2 {
|
||||
t.Fatal("超管漏掉官方区服流水")
|
||||
}
|
||||
f.ServerNum = "001"
|
||||
p, err = r.Aggregate(ctx, s, f)
|
||||
if err != nil || len(p.Rows) != 1 {
|
||||
t.Fatal("区服编号筛选错误")
|
||||
}
|
||||
options, err := r.Servers(ctx, []string{"lx"}, []string{"游戏甲"}, "一区", 50)
|
||||
if err != nil || len(options) != 1 || options[0].Number != "001" || options[0].Name != "龙翔一区" {
|
||||
t.Fatal("选项应按当前游戏名、同平台游戏关联及区服名称筛选")
|
||||
}
|
||||
f.GameNames = []string{"游戏甲"}
|
||||
p, err = r.Aggregate(ctx, s, f)
|
||||
if err != nil || len(p.Rows) != 0 {
|
||||
t.Fatal("报表必须按快照游戏名筛选")
|
||||
}
|
||||
if err := db.Exec("DROP TABLE tab_sw_report_server_day_key").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err = r.Aggregate(ctx, s, f)
|
||||
if err != nil || p.Available {
|
||||
t.Fatal("缺身份表应明确未接入")
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ var ProviderSet = wire.NewSet(
|
||||
databusiness.NewChannelPortraitRepo,
|
||||
databusiness.NewLedgerRepo,
|
||||
databusiness.NewAnalyticsRepo,
|
||||
databusiness.NewServerAnalysisRepo,
|
||||
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_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},
|
||||
{Name: "sw_DataAnalysis", Path: "sw-DataAnalysis", Component: "view/routerHolder.vue", Title: "数据分析", Icon: "data-analysis", Sort: 3},
|
||||
|
||||
@@ -33,8 +33,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
||||
menus += len(definition.Surface.Menus)
|
||||
apis += len(definition.Surface.APIs)
|
||||
}
|
||||
if menus != 16 || apis != 60 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 16/60", menus, apis)
|
||||
if menus != 17 || apis != 62 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 17/62", menus, apis)
|
||||
}
|
||||
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
||||
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
||||
|
||||
@@ -41,6 +41,8 @@ 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/serverAnalysis/servers": {group: "区服分析", description: "查询游戏区服选项"},
|
||||
"GET /sw/serverAnalysis/list": {group: "区服分析", description: "查询区服分析与汇总"},
|
||||
"GET /sw/channelData/list": {group: "渠道统计", description: "查询渠道数据与汇总"},
|
||||
"GET /sw/gameData/list": {group: "游戏统计", description: "查询游戏数据与汇总"},
|
||||
"GET /sw/register/list": {group: "注册明细", description: "查询注册明细"},
|
||||
|
||||
@@ -27,6 +27,7 @@ func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlServ
|
||||
func emptyHandlers() *handler.Set {
|
||||
return &handler.Set{
|
||||
Analytics: &handler.Analytics{},
|
||||
ServerAnalysis: &handler.ServerAnalysis{},
|
||||
Ledger: &handler.Ledger{},
|
||||
ChannelPortrait: &handler.ChannelPortrait{},
|
||||
BusinessPortrait: &handler.BusinessPortrait{},
|
||||
@@ -107,7 +108,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":243`) {
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":245`) {
|
||||
t.Fatalf("startup route summary is missing: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewChannelPortrait,
|
||||
NewLedger,
|
||||
NewAnalytics,
|
||||
NewServerAnalysis,
|
||||
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
|
||||
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
|
||||
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
"kra/internal/service/business"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type ServerAnalysis struct {
|
||||
service *business.ServerAnalysisService
|
||||
}
|
||||
|
||||
func NewServerAnalysis(service *business.ServerAnalysisService) *ServerAnalysis {
|
||||
return &ServerAnalysis{service: service}
|
||||
}
|
||||
func (h *ServerAnalysis) List(c *gin.Context) {
|
||||
var req dto.ServerAnalysisQuery
|
||||
if c.ShouldBindQuery(&req) != nil {
|
||||
Fail(c, "区服查询参数不正确")
|
||||
return
|
||||
}
|
||||
data, err := h.service.List(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
Fail(c, errors.FromError(err).Message)
|
||||
return
|
||||
}
|
||||
OKWithData(c, data)
|
||||
}
|
||||
func (h *ServerAnalysis) Servers(c *gin.Context) {
|
||||
var req dto.ServerOptionQuery
|
||||
if c.ShouldBindQuery(&req) != nil {
|
||||
Fail(c, "区服查询参数不正确")
|
||||
return
|
||||
}
|
||||
data, err := h.service.Servers(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
Fail(c, errors.FromError(err).Message)
|
||||
return
|
||||
}
|
||||
OKWithData(c, data)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
type Set struct {
|
||||
ServerAnalysis *ServerAnalysis
|
||||
Analytics *Analytics
|
||||
Ledger *Ledger
|
||||
ChannelPortrait *ChannelPortrait
|
||||
|
||||
@@ -20,6 +20,10 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
||||
return
|
||||
}
|
||||
registrations := []func(){
|
||||
func() {
|
||||
private.GET("/sw/serverAnalysis/servers", r.handlers.ServerAnalysis.Servers)
|
||||
private.GET("/sw/serverAnalysis/list", r.handlers.ServerAnalysis.List)
|
||||
},
|
||||
func() {
|
||||
private.GET("/sw/channelData/list", r.handlers.Analytics.Channels)
|
||||
private.GET("/sw/gameData/list", r.handlers.Analytics.Games)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/business"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type ServerAnalysisUsecase interface {
|
||||
List(context.Context, business.ServerAnalysisFilter, int, int) (*business.ServerAnalysisPage, error)
|
||||
Servers(context.Context, []string, []string, string, int) ([]*business.ServerOption, error)
|
||||
}
|
||||
type ServerAnalysisService struct{ uc ServerAnalysisUsecase }
|
||||
|
||||
func NewServerAnalysisService(uc *business.ServerAnalysisUsecase) *ServerAnalysisService {
|
||||
return &ServerAnalysisService{uc: uc}
|
||||
}
|
||||
func serverAnalysisResponse(row *business.ServerAnalysisRow, summary bool) *dto.ServerAnalysisRow {
|
||||
out := &dto.ServerAnalysisRow{SourceDB: row.SourceDB, SourceDBLabel: business.PlatformName(row.SourceDB), GameName: row.GameName, ServerOptionResponse: dto.ServerOptionResponse{Number: row.ServerNum, Name: row.ServerName, OpenTime: row.OpenTime}, Register: row.Counts.Register, Active: row.Counts.Active, PayUsers: row.Counts.Pay, Pay: row.Pay, ARPU: row.ARPU}
|
||||
if !summary {
|
||||
out.GameID = &row.GameID
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (s *ServerAnalysisService) List(ctx context.Context, req *dto.ServerAnalysisQuery) (*dto.ServerAnalysisPage, error) {
|
||||
if req == nil {
|
||||
return nil, business.ErrAnalyticsInput
|
||||
}
|
||||
f, err := analyticsRequest(&dto.AnalyticsQuery{Sources: req.Sources, GameNames: req.GameNames, BeginTime: req.BeginTime, EndTime: req.EndTime, SortField: req.SortField, SortOrder: req.SortOrder})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page, err := s.uc.List(ctx, business.ServerAnalysisFilter{AnalyticsFilter: f, ServerNum: req.ServerNum}, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &dto.ServerAnalysisPage{Rows: []*dto.ServerAnalysisRow{}, Total: page.Total, Begin: page.Begin, End: page.End, Available: page.Available}
|
||||
for _, row := range page.Rows {
|
||||
out.Rows = append(out.Rows, serverAnalysisResponse(row, false))
|
||||
}
|
||||
if page.Summary != nil {
|
||||
out.Summary = serverAnalysisResponse(page.Summary, true)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *ServerAnalysisService) Servers(ctx context.Context, req *dto.ServerOptionQuery) ([]*dto.ServerOptionResponse, error) {
|
||||
if req == nil {
|
||||
return nil, business.ErrAnalyticsInput
|
||||
}
|
||||
sources, err := normalizePlatforms(req.Sources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.uc.Servers(ctx, sources, splitQueryValues(req.GameNames), req.Keyword, req.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*dto.ServerOptionResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, &dto.ServerOptionResponse{Number: row.Number, Name: row.Name, OpenTime: row.OpenTime})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package dto
|
||||
|
||||
type ServerAnalysisQuery struct {
|
||||
Page int `form:"pageNum"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Sources []string `form:"sourceDb"`
|
||||
GameNames []string `form:"gameName"`
|
||||
ServerNum string `form:"serverNum"`
|
||||
BeginTime string `form:"beginTime"`
|
||||
EndTime string `form:"endTime"`
|
||||
SortField string `form:"sortField"`
|
||||
SortOrder string `form:"sortOrder"`
|
||||
}
|
||||
type ServerOptionQuery struct {
|
||||
Sources []string `form:"sourceDb"`
|
||||
GameNames []string `form:"gameName"`
|
||||
Keyword string `form:"keyword"`
|
||||
Limit int `form:"limit"`
|
||||
}
|
||||
type ServerOptionResponse struct {
|
||||
Number string `json:"serverNum"`
|
||||
Name string `json:"serverName"`
|
||||
OpenTime *int64 `json:"openTime"`
|
||||
}
|
||||
type ServerAnalysisRow struct {
|
||||
SourceDB string `json:"sourceDb"`
|
||||
SourceDBLabel string `json:"sourceDbLabel"`
|
||||
GameID *int64 `json:"gameId"`
|
||||
GameName string `json:"gameName"`
|
||||
ServerOptionResponse
|
||||
Register int64 `json:"newRegisterCount"`
|
||||
Active int64 `json:"activeCount"`
|
||||
PayUsers int64 `json:"payUserCount"`
|
||||
Pay string `json:"totalPay"`
|
||||
ARPU string `json:"arpu"`
|
||||
}
|
||||
type ServerAnalysisPage struct {
|
||||
Rows []*ServerAnalysisRow `json:"rows"`
|
||||
Total int64 `json:"total"`
|
||||
Summary *ServerAnalysisRow `json:"summary"`
|
||||
Begin string `json:"beginTime"`
|
||||
End string `json:"endTime"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
@@ -25,6 +25,7 @@ var ProviderSet = wire.NewSet(
|
||||
businessservice.NewChannelPortraitService,
|
||||
businessservice.NewLedgerService,
|
||||
businessservice.NewAnalyticsService,
|
||||
businessservice.NewServerAnalysisService,
|
||||
systemservice.ProviderSet,
|
||||
paymentservice.ProviderSet,
|
||||
integrationservice.ProviderSet,
|
||||
|
||||
Reference in New Issue
Block a user