feat: 迁移渠道与游戏统计及全量汇总

This commit is contained in:
Yvan
2026-11-04 01:08:33 +08:00
parent 6703a66f0d
commit fc27120950
31 changed files with 1492 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)
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)
+280
View File
@@ -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
}
+78
View File
@@ -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 &copy, 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("倒置日期应被拒绝")
}
}