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)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAnalyticsInput = errors.BadRequest("SW_ANALYTICS_INPUT", "统计查询参数不正确")
|
||||
ErrAnalyticsDate = errors.BadRequest("SW_ANALYTICS_DATE", "统计周期格式错误,应为 yyyy-MM-dd")
|
||||
ErrAnalyticsDateOrder = errors.BadRequest("SW_ANALYTICS_DATE_ORDER", "统计开始日期不能晚于结束日期")
|
||||
)
|
||||
|
||||
type AnalyticsKind string
|
||||
|
||||
const (
|
||||
ChannelAnalytics AnalyticsKind = "channel"
|
||||
GameAnalytics AnalyticsKind = "game"
|
||||
)
|
||||
|
||||
type AnalyticsKey struct {
|
||||
SourceDB string
|
||||
ID int64
|
||||
}
|
||||
type AnalyticsFilter struct {
|
||||
Sources, GameNames []string
|
||||
PromoteName string
|
||||
PromoteType *int
|
||||
StartDate, EndDate *time.Time
|
||||
Begin, End, SortField string
|
||||
Ascending bool
|
||||
}
|
||||
type AnalyticsScope struct {
|
||||
Viewer UserScope
|
||||
IncludeSub, IDCardDedup bool
|
||||
}
|
||||
type AnalyticsRow struct {
|
||||
AnalyticsKey
|
||||
Name string
|
||||
PromoteType *int
|
||||
BindingAccount, BindingUserName string
|
||||
Counts PortraitCounts
|
||||
Pay, PayRate, ARPU, ARPPU string
|
||||
}
|
||||
|
||||
func (r *AnalyticsRow) BusinessName() string {
|
||||
if value := strings.TrimSpace(r.BindingAccount); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(r.BindingUserName)
|
||||
}
|
||||
|
||||
type AnalyticsData struct {
|
||||
Rows []*AnalyticsRow
|
||||
GlobalCounts *PortraitCounts
|
||||
GlobalPay string
|
||||
Available bool
|
||||
}
|
||||
type AnalyticsPage struct {
|
||||
Rows []*AnalyticsRow
|
||||
Total int64
|
||||
Summary *AnalyticsRow
|
||||
Begin, End string
|
||||
Available bool
|
||||
}
|
||||
type AnalyticsRepo interface {
|
||||
Aggregate(context.Context, AnalyticsKind, AnalyticsScope, AnalyticsFilter) (*AnalyticsData, error)
|
||||
}
|
||||
type AnalyticsUsecase struct {
|
||||
repo AnalyticsRepo
|
||||
accounts *AccountUsecase
|
||||
preferences *UserPreferenceUsecase
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewAnalyticsUsecase(repo AnalyticsRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *AnalyticsUsecase {
|
||||
return &AnalyticsUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
|
||||
}
|
||||
func analyticsCap(c PortraitCounts) PortraitCounts {
|
||||
return PortraitCounts{Register: min(c.Register, 2147483647), Active: min(c.Active, 2147483647), Pay: min(c.Pay, 2147483647), NewPay: min(c.NewPay, 2147483647)}
|
||||
}
|
||||
func analyticsAdd(a, b PortraitCounts) PortraitCounts {
|
||||
return PortraitCounts{Register: a.Register + b.Register, Active: a.Active + b.Active, Pay: a.Pay + b.Pay, NewPay: a.NewPay + b.NewPay}
|
||||
}
|
||||
func analyticsRatio(numerator *big.Rat, denominator int64, percent bool) string {
|
||||
if denominator <= 0 {
|
||||
return "0.00"
|
||||
}
|
||||
n := new(big.Rat).Quo(numerator, big.NewRat(denominator, 1))
|
||||
if percent {
|
||||
n.Mul(n, big.NewRat(100, 1))
|
||||
}
|
||||
return portraitDecimal(n, 2)
|
||||
}
|
||||
func analyticsValues(row *AnalyticsRow) error {
|
||||
pay, err := portraitNumber(row.Pay)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.Pay = portraitDecimal(pay, 2)
|
||||
pay, _ = portraitNumber(row.Pay)
|
||||
row.PayRate = analyticsRatio(big.NewRat(row.Counts.Pay, 1), row.Counts.Active, true)
|
||||
row.ARPU = analyticsRatio(pay, row.Counts.Active, false)
|
||||
row.ARPPU = analyticsRatio(pay, row.Counts.Pay, false)
|
||||
return nil
|
||||
}
|
||||
func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter AnalyticsFilter, page, size int) (*AnalyticsPage, error) {
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pref, err := uc.preferences.Current(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind != ChannelAnalytics && kind != GameAnalytics {
|
||||
return nil, ErrAnalyticsInput
|
||||
}
|
||||
viewer := portraitUserScope(account)
|
||||
viewer.All = actor.AuthorityID == 888
|
||||
if kind == GameAnalytics {
|
||||
filter.PromoteName = ""
|
||||
filter.PromoteType = nil
|
||||
}
|
||||
if filter.StartDate == nil || filter.EndDate == nil {
|
||||
now := uc.now().In(portraitLocation)
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, portraitLocation)
|
||||
filter.StartDate = &day
|
||||
filter.EndDate = &day
|
||||
}
|
||||
if filter.EndDate.Before(*filter.StartDate) {
|
||||
return nil, ErrAnalyticsDateOrder
|
||||
}
|
||||
filter.Begin = filter.StartDate.Format("2006-01-02")
|
||||
filter.End = filter.EndDate.Format("2006-01-02")
|
||||
out := &AnalyticsPage{Rows: []*AnalyticsRow{}, Begin: filter.Begin, End: filter.End, Available: true}
|
||||
data, err := uc.repo.Aggregate(ctx, kind, AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !data.Available {
|
||||
out.Available = false
|
||||
return out, nil
|
||||
}
|
||||
counts := PortraitCounts{}
|
||||
pay := new(big.Rat)
|
||||
for _, row := range data.Rows {
|
||||
row.Counts = analyticsCap(row.Counts)
|
||||
raw, err := portraitNumber(row.Pay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind == ChannelAnalytics {
|
||||
pay.Add(pay, raw)
|
||||
}
|
||||
if err := analyticsValues(row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind == GameAnalytics {
|
||||
rounded, _ := portraitNumber(row.Pay)
|
||||
pay.Add(pay, rounded)
|
||||
}
|
||||
counts = analyticsAdd(counts, row.Counts)
|
||||
if kind == ChannelAnalytics {
|
||||
row.Name = (&LedgerNames{PromoteID: &row.ID, PromoteAccount: row.Name}).DisplayPromote()
|
||||
}
|
||||
}
|
||||
if kind == ChannelAnalytics {
|
||||
counts = analyticsCap(counts)
|
||||
if viewer.All && data.GlobalCounts != nil {
|
||||
counts = analyticsCap(*data.GlobalCounts)
|
||||
pay, err = portraitNumber(data.GlobalPay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
label := "全部渠道"
|
||||
if kind == GameAnalytics {
|
||||
label = "全部游戏"
|
||||
}
|
||||
out.Summary = &AnalyticsRow{Name: label, Counts: counts, Pay: portraitDecimal(pay, 2)}
|
||||
if err := analyticsValues(out.Summary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := sortAnalytics(data.Rows, kind, filter.SortField, filter.Ascending); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Total = int64(len(data.Rows))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 || size > 500 {
|
||||
size = 500
|
||||
}
|
||||
if page > len(data.Rows)/size+1 {
|
||||
return out, nil
|
||||
}
|
||||
from := (page - 1) * size
|
||||
to := min(from+size, len(data.Rows))
|
||||
if from < len(data.Rows) {
|
||||
out.Rows = data.Rows[from:to]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func sortAnalytics(rows []*AnalyticsRow, kind AnalyticsKind, field string, ascending bool) error {
|
||||
allowed := map[string]bool{"newRegisterCount": true, "activeCount": true, "payUserCount": true, "newPayUserCount": true, "totalPay": true, "payRate": true}
|
||||
if kind == GameAnalytics {
|
||||
allowed["arpu"] = true
|
||||
allowed["arppu"] = true
|
||||
} else {
|
||||
allowed["promoteId"] = true
|
||||
allowed["promoteName"] = true
|
||||
allowed["promoteType"] = true
|
||||
}
|
||||
if !allowed[field] {
|
||||
field = "totalPay"
|
||||
}
|
||||
values := map[*AnalyticsRow]*big.Rat{}
|
||||
for _, row := range rows {
|
||||
var n *big.Rat
|
||||
var err error
|
||||
switch field {
|
||||
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 "newPayUserCount":
|
||||
n = big.NewRat(row.Counts.NewPay, 1)
|
||||
case "promoteId":
|
||||
n = big.NewRat(row.ID, 1)
|
||||
case "promoteType":
|
||||
value := int64(0)
|
||||
if row.PromoteType != nil {
|
||||
value = int64(*row.PromoteType)
|
||||
}
|
||||
n = big.NewRat(value, 1)
|
||||
case "payRate":
|
||||
n, err = portraitNumber(row.PayRate)
|
||||
case "arpu":
|
||||
n, err = portraitNumber(row.ARPU)
|
||||
case "arppu":
|
||||
n, err = portraitNumber(row.ARPPU)
|
||||
case "promoteName":
|
||||
continue
|
||||
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]
|
||||
comparison := 0
|
||||
if field == "promoteName" {
|
||||
comparison = strings.Compare(a.Name, b.Name)
|
||||
} else {
|
||||
comparison = values[a].Cmp(values[b])
|
||||
}
|
||||
if comparison != 0 {
|
||||
if ascending {
|
||||
return comparison < 0
|
||||
}
|
||||
return comparison > 0
|
||||
}
|
||||
if a.SourceDB != b.SourceDB {
|
||||
return a.SourceDB < b.SourceDB
|
||||
}
|
||||
return a.ID < b.ID
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type analyticsRepoFake struct {
|
||||
data *AnalyticsData
|
||||
scope AnalyticsScope
|
||||
filter AnalyticsFilter
|
||||
}
|
||||
|
||||
func (r *analyticsRepoFake) Aggregate(_ context.Context, _ AnalyticsKind, s AnalyticsScope, f AnalyticsFilter) (*AnalyticsData, error) {
|
||||
r.scope = s
|
||||
r.filter = f
|
||||
copy := *r.data
|
||||
copy.Rows = []*AnalyticsRow{}
|
||||
for _, row := range r.data.Rows {
|
||||
c := *row
|
||||
copy.Rows = append(copy.Rows, &c)
|
||||
}
|
||||
return ©, nil
|
||||
}
|
||||
func analyticsFixture() (*AnalyticsUsecase, *analyticsRepoFake) {
|
||||
a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1}, 7: {ID: 7, Level: 1, IDPath: "0"}}}
|
||||
r := &analyticsRepoFake{data: &AnalyticsData{Available: true, Rows: []*AnalyticsRow{{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: 1}, Name: "甲", Counts: PortraitCounts{Register: 2, Active: 4, Pay: 2, NewPay: 1}, Pay: "20.00"}, {AnalyticsKey: AnalyticsKey{SourceDB: "hw", ID: 2}, Name: "乙", Counts: PortraitCounts{Register: 3, Active: 6, Pay: 1, NewPay: 1}, Pay: "30.00"}}, GlobalCounts: &PortraitCounts{Register: 4, Active: 8, Pay: 3}, GlobalPay: "45.00"}}
|
||||
uc := NewAnalyticsUsecase(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) }
|
||||
return uc, r
|
||||
}
|
||||
func TestGameAnalyticsSummaryUsesAllRowsAndCorrectDenominators(t *testing.T) {
|
||||
uc, _ := analyticsFixture()
|
||||
page, err := uc.List(accountContext(1, 888), GameAnalytics, AnalyticsFilter{}, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Rows) != 1 || page.Total != 2 || page.Rows[0].ID != 2 || page.Summary.Counts.Register != 5 || page.Summary.Pay != "50.00" || page.Summary.PayRate != "30.00" || page.Summary.ARPU != "5.00" || page.Summary.ARPPU != "16.67" {
|
||||
t.Fatalf("游戏汇总、分页或分母错误: %+v", page)
|
||||
}
|
||||
if page.Begin != "2026-11-04" || page.End != "2026-11-04" {
|
||||
t.Fatal("后台缺省统计日期应为当天")
|
||||
}
|
||||
}
|
||||
func TestChannelAnalyticsSummaryDependsOnViewer(t *testing.T) {
|
||||
uc, _ := analyticsFixture()
|
||||
page, err := uc.List(accountContext(7, 901), ChannelAnalytics, AnalyticsFilter{}, 1, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Summary.Counts.Register != 5 || page.Summary.Pay != "50.00" {
|
||||
t.Fatal("普通商务应汇总可见展示行")
|
||||
}
|
||||
page, err = uc.List(accountContext(1, 888), ChannelAnalytics, AnalyticsFilter{}, 1, 10)
|
||||
if err != nil || page.Summary.Counts.Register != 4 || page.Summary.Pay != "45.00" || page.Summary.PayRate != "37.50" {
|
||||
t.Fatal("超管应使用筛选范围内全局去重汇总")
|
||||
}
|
||||
}
|
||||
func TestAnalyticsSortAndMissingData(t *testing.T) {
|
||||
uc, r := analyticsFixture()
|
||||
r.data.Rows[0].Pay = "9007199254740993.00"
|
||||
r.data.Rows[1].Pay = "9007199254740992.99"
|
||||
p, err := uc.List(accountContext(7, 901), GameAnalytics, AnalyticsFilter{SortField: "totalPay"}, 1, 10)
|
||||
if err != nil || p.Rows[0].ID != 1 {
|
||||
t.Fatal("金额排序不能经浮点数失真")
|
||||
}
|
||||
r.data.Available = false
|
||||
p, err = uc.List(accountContext(7, 901), ChannelAnalytics, AnalyticsFilter{}, 1, 10)
|
||||
if err != nil || p.Available || p.Summary != nil {
|
||||
t.Fatal("缺汇总表不能伪造零卡片")
|
||||
}
|
||||
begin := time.Date(2026, 11, 4, 0, 0, 0, 0, portraitLocation)
|
||||
end := begin.AddDate(0, 0, -1)
|
||||
if _, err := uc.List(accountContext(1, 888), GameAnalytics, AnalyticsFilter{StartDate: &begin, EndDate: &end}, 1, 10); err != ErrAnalyticsDateOrder {
|
||||
t.Fatal("倒置日期应被拒绝")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user