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("倒置日期应被拒绝")
}
}
+307
View File
@@ -0,0 +1,307 @@
package business
import (
"context"
"sort"
"gorm.io/gorm"
"kra/internal/biz/business"
)
type analyticsRepo struct{ data Provider }
func NewAnalyticsRepo(data Provider) business.AnalyticsRepo { return &analyticsRepo{data: data} }
func analyticsBasePromotes(db *gorm.DB, scope business.AnalyticsScope, f business.AnalyticsFilter, alias string) *gorm.DB {
q := ledgerBasePromotes(db, business.LedgerScope{Viewer: scope.Viewer, IncludeSub: scope.IncludeSub, Unrestricted: scope.Viewer.All}, business.LedgerFilter{Sources: f.Sources}, alias)
if f.PromoteType != nil {
q = q.Where(alias+".register_type = ?", *f.PromoteType)
}
return q
}
func analyticsPromotes(db *gorm.DB, scope business.AnalyticsScope, f business.AnalyticsFilter, alias string) *gorm.DB {
q := analyticsBasePromotes(db, scope, f, alias)
if f.PromoteName == "" {
return q
}
hits := analyticsBasePromotes(db, scope, f, "hit").Select("1").Where("hit.account LIKE ?", "%"+f.PromoteName+"%").Where("hit.source_db=" + alias + ".source_db")
if scope.IncludeSub {
root := "CASE WHEN hit.promote_level=1 THEN hit.id WHEN hit.top_promote_id>0 THEN hit.top_promote_id ELSE hit.id END"
hits = hits.Where("(" + alias + ".id=(" + root + ") OR " + alias + ".top_promote_id=(" + root + "))")
} else {
hits = hits.Where("hit.id=" + alias + ".id")
}
return q.Where("EXISTS (?)", hits)
}
func prepareAnalytics(db *gorm.DB, scope business.AnalyticsScope, f business.AnalyticsFilter) (bool, bool, error) {
if !scope.Viewer.All {
var binds []struct{ UserID uint }
if err := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{Sources: f.Sources}).Select("b.user_id").Limit(1).Scan(&binds).Error; err != nil {
return false, false, business.ErrStorage
}
if len(binds) == 0 {
return true, true, nil
}
}
available, err := businessTableAvailable(db, "tab_promote")
if err != nil || !available {
return false, available, err
}
var rows []struct{ ID int64 }
if err := analyticsPromotes(db, scope, f, "p").Select("p.id").Limit(1).Scan(&rows).Error; err != nil {
return false, false, business.ErrStorage
}
return len(rows) == 0, true, nil
}
func analyticsQuery(db *gorm.DB, scope business.AnalyticsScope, f business.AnalyticsFilter, table string, mode int) *gorm.DB {
q := db.Table(table+" r").Where("r.stat_date >= ? AND r.stat_date <= ? AND r.dedup_mode = ? AND r.game_id > 0", f.Begin, f.End, mode)
if len(f.Sources) > 0 {
q = q.Where("r.source_db IN ?", f.Sources)
}
if len(f.GameNames) > 0 {
q = q.Where("r.game_name IN ?", f.GameNames)
}
if scope.Viewer.All && scope.IncludeSub && f.PromoteType == nil && f.PromoteName == "" {
return q
}
return q.Where("EXISTS (?)", analyticsPromotes(db, scope, f, "p").Select("1").Where("p.source_db=r.source_db AND p.id=r.promote_id"))
}
type analyticsAggPO struct {
SourceDB string `gorm:"column:source_db"`
ID *int64 `gorm:"column:entity_id"`
Metric int
Count int64
Amount string
}
func analyticsRoot(db *gorm.DB, kind business.AnalyticsKind, scope business.AnalyticsScope, f business.AnalyticsFilter, table string, mode int, pay bool) (*gorm.DB, string) {
q := analyticsQuery(db, scope, f, table, mode)
id := "r.game_id"
if kind == business.ChannelAnalytics {
id = "r.promote_id"
if scope.IncludeSub {
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)"
if pay {
id = "CASE WHEN root_p.promote_level=1 THEN root_p.id WHEN root_p.top_promote_id>0 THEN root_p.top_promote_id ELSE COALESCE(root_p.id,r.promote_id) END"
}
}
}
return q, id
}
func putAnalyticsCount(counts *business.PortraitCounts, metric int, count int64) {
switch metric {
case 1:
counts.Register = count
case 2:
counts.Active = count
case 3:
counts.Pay = count
case 4:
counts.NewPay = count
}
}
func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKind, scope business.AnalyticsScope, f business.AnalyticsFilter) (*business.AnalyticsData, error) {
db := r.data.DB().WithContext(ctx)
result := &business.AnalyticsData{Rows: []*business.AnalyticsRow{}, GlobalPay: "0", Available: true}
empty, available, err := prepareAnalytics(db, scope, f)
if err != nil {
return nil, err
}
result.Available = available
if empty || !available {
return result, 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 {
result.Available = false
return result, nil
}
}
mode := 0
if scope.IDCardDedup {
mode = 1
}
rowMap := map[business.AnalyticsKey]*business.AnalyticsRow{}
rowFor := func(po analyticsAggPO) *business.AnalyticsRow {
key := business.AnalyticsKey{SourceDB: po.SourceDB, ID: *po.ID}
if rowMap[key] == nil {
rowMap[key] = &business.AnalyticsRow{AnalyticsKey: key, Pay: "0"}
}
return rowMap[key]
}
var pays []analyticsAggPO
q, id := analyticsRoot(db, kind, scope, f, "tab_sw_report_day", 0, true)
if err := q.Select("r.source_db," + id + " AS entity_id,COALESCE(SUM(r.total_pay),0) AS amount").Group("r.source_db," + id).Scan(&pays).Error; err != nil {
return nil, business.ErrStorage
}
for _, po := range pays {
if po.ID == nil {
continue
}
rowFor(po).Pay = po.Amount
}
var counts []analyticsAggPO
q, id = analyticsRoot(db, kind, scope, f, "tab_sw_report_day_key", mode, false)
if err := q.Select("r.source_db," + id + " AS entity_id,r.metric,COUNT(DISTINCT r.identity_key) AS count").Group("r.source_db," + id + ",r.metric").Scan(&counts).Error; err != nil {
return nil, business.ErrStorage
}
for _, po := range counts {
if po.ID == nil {
continue
}
putAnalyticsCount(&rowFor(po).Counts, po.Metric, po.Count)
}
if kind == business.ChannelAnalytics && !scope.Viewer.All {
if err := filterAnalyticsRows(db, scope, f, rowMap); err != nil {
return nil, err
}
}
if kind == business.ChannelAnalytics && scope.Viewer.All {
var global []portraitCountPO
q := analyticsQuery(db, scope, f, "tab_sw_report_day_key", mode)
if err := q.Select("r.metric,COUNT(DISTINCT " + portraitIdentity(db, scope.IDCardDedup) + ") AS count").Group("r.metric").Scan(&global).Error; err != nil {
return nil, business.ErrStorage
}
totals := toPortraitCounts(global)
result.GlobalCounts = &totals
if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(&result.GlobalPay).Error; err != nil {
return nil, business.ErrStorage
}
}
if len(rowMap) == 0 {
return result, nil
}
if kind == business.GameAnalytics {
available, err := businessTableAvailable(db, "tab_game")
if err != nil {
return nil, err
}
if !available {
result.Available = false
return result, nil
}
}
if err := fillAnalyticsNames(db, kind, scope, f, rowMap); err != nil {
return nil, err
}
for _, row := range rowMap {
result.Rows = append(result.Rows, row)
}
sort.Slice(result.Rows, func(i, j int) bool {
a, b := result.Rows[i], result.Rows[j]
if a.SourceDB != b.SourceDB {
return a.SourceDB < b.SourceDB
}
return a.ID < b.ID
})
return result, nil
}
func analyticsPairs(keys []business.AnalyticsKey) [][]any {
result := make([][]any, 0, len(keys))
for _, key := range keys {
result = append(result, []any{key.SourceDB, key.ID})
}
return result
}
func filterAnalyticsRows(db *gorm.DB, scope business.AnalyticsScope, f business.AnalyticsFilter, rows map[business.AnalyticsKey]*business.AnalyticsRow) error {
keys := []business.AnalyticsKey{}
for key := range rows {
keys = append(keys, key)
}
allowed := map[business.AnalyticsKey]bool{}
root := "p.id"
if scope.IncludeSub {
root = "CASE WHEN p.promote_level=1 THEN p.id WHEN p.top_promote_id>0 THEN p.top_promote_id ELSE p.id END"
}
for start := 0; start < len(keys); start += 300 {
var selected []analyticsAggPO
q := analyticsPromotes(db, scope, f, "p").Select("DISTINCT p.source_db,"+root+" AS entity_id").Where("(p.source_db,"+root+") IN ?", analyticsPairs(keys[start:min(start+300, len(keys))]))
if err := q.Scan(&selected).Error; err != nil {
return business.ErrStorage
}
for _, row := range selected {
if row.ID != nil {
allowed[business.AnalyticsKey{SourceDB: row.SourceDB, ID: *row.ID}] = true
}
}
}
for key := range rows {
if !allowed[key] {
delete(rows, key)
}
}
return nil
}
func fillAnalyticsNames(db *gorm.DB, kind business.AnalyticsKind, scope business.AnalyticsScope, f business.AnalyticsFilter, rows map[business.AnalyticsKey]*business.AnalyticsRow) error {
keys := []business.AnalyticsKey{}
for key := range rows {
keys = append(keys, key)
}
type entityPO struct {
ID int64
SourceDB string `gorm:"column:source_db"`
Name string
RegisterType *int
BusierID *int64
}
promotes := map[business.AnalyticsKey]entityPO{}
bindings := map[business.PlatformKey]struct{ account, user string }{}
pairs := []business.PlatformKey{}
pairSeen := map[business.PlatformKey]bool{}
for start := 0; start < len(keys); start += 300 {
var entities []entityPO
q := db.Table("tab_game").Select("id,source_db,game_name AS name")
if kind == business.ChannelAnalytics {
q = db.Table("tab_promote").Select("id,source_db,account AS name,register_type,busier_id")
}
if err := q.Where("(source_db,id) IN ?", analyticsPairs(keys[start:min(start+300, len(keys))])).Scan(&entities).Error; err != nil {
return business.ErrStorage
}
for _, entity := range entities {
key := business.AnalyticsKey{SourceDB: entity.SourceDB, ID: entity.ID}
if row := rows[key]; row != nil {
row.Name = entity.Name
row.PromoteType = entity.RegisterType
promotes[key] = entity
}
if kind == business.ChannelAnalytics && entity.BusierID != nil && *entity.BusierID > 0 {
pair := business.PlatformKey{SourceDB: entity.SourceDB, BusierID: uint64(*entity.BusierID)}
if !pairSeen[pair] {
pairs = append(pairs, pair)
pairSeen[pair] = true
}
}
}
}
for start := 0; start < len(pairs); start += 300 {
var names []struct {
SourceDB string `gorm:"column:source_db"`
PlatformBusierID uint64
PlatformBusierAccount, Username string
}
if err := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{Sources: f.Sources}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,u.username").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id").Scan(&names).Error; err != nil {
return business.ErrStorage
}
for _, name := range names {
key := business.PlatformKey{SourceDB: name.SourceDB, BusierID: name.PlatformBusierID}
if _, ok := bindings[key]; !ok {
bindings[key] = struct{ account, user string }{name.PlatformBusierAccount, name.Username}
}
}
}
for key, entity := range promotes {
if entity.BusierID == nil || *entity.BusierID <= 0 {
continue
}
if name, ok := bindings[business.PlatformKey{SourceDB: entity.SourceDB, BusierID: uint64(*entity.BusierID)}]; ok {
rows[key].BindingAccount = name.account
rows[key].BindingUserName = name.user
}
}
return nil
}
+111
View File
@@ -0,0 +1,111 @@
package business
import (
"context"
"testing"
"gorm.io/gorm"
"kra/internal/biz/business"
)
func prepareAnalyticsData(t *testing.T) (*gorm.DB, business.AnalyticsRepo, business.AnalyticsScope, business.AnalyticsFilter) {
t.Helper()
db, _ := prepareChannels(t)
for _, sql := range []string{
"ALTER TABLE tab_sw_report_day ADD COLUMN game_name TEXT",
"ALTER TABLE tab_sw_report_day_key ADD COLUMN game_name TEXT",
"UPDATE tab_sw_report_day SET game_name=CASE WHEN game_id=1 THEN '历史游戏甲' ELSE '游戏乙' END",
"UPDATE tab_sw_report_day_key SET game_name=CASE WHEN game_id=1 THEN '历史游戏甲' ELSE '游戏乙' END",
"UPDATE tab_sw_report_day_key SET identity_key='A' WHERE (source_db='hw' OR promote_id=103) AND metric=1",
"INSERT INTO tab_sw_report_day(source_db,promote_id,game_id,dedup_mode,stat_date,total_pay,game_name) VALUES('lx',0,1,0,'2026-11-01',5,'历史游戏甲'),('lx',999,1,0,'2026-11-01',7,'历史游戏甲')",
"INSERT INTO tab_sw_report_day_key(source_db,promote_id,game_id,dedup_mode,stat_date,metric,identity_key,game_name) VALUES('lx',0,1,1,'2026-11-01',1,'official','历史游戏甲'),('lx',999,1,1,'2026-11-01',1,'orphan','历史游戏甲'),('lx',100,1,1,'2026-11-01',2,'A','历史游戏甲'),('lx',103,1,1,'2026-11-02',2,'A','历史游戏甲'),('hw',100,1,1,'2026-11-01',2,'A','历史游戏甲'),('lx',100,1,1,'2026-11-01',3,'A','历史游戏甲'),('lx',103,1,1,'2026-11-02',3,'A','历史游戏甲'),('hw',100,1,1,'2026-11-01',3,'A','历史游戏甲')",
} {
if err := db.Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
scope := business.AnalyticsScope{Viewer: channelTestScope(), IncludeSub: true, IDCardDedup: true}
filter := business.AnalyticsFilter{Sources: []string{"lx", "hw"}, Begin: "2026-11-01", End: "2026-11-03"}
return db, NewAnalyticsRepo(testData{db}), scope, filter
}
func TestAnalyticsChannelTreeAndAdminOfficialRows(t *testing.T) {
_, repo, scope, filter := prepareAnalyticsData(t)
data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
if err != nil {
t.Fatal(err)
}
if len(data.Rows) != 3 || data.GlobalCounts != nil {
t.Fatalf("普通商务统计范围错误: %+v", data)
}
var lx *business.AnalyticsRow
for _, row := range data.Rows {
if row.SourceDB == "lx" && row.ID == 100 {
lx = row
}
if row.ID == 0 || row.ID == 999 {
t.Fatal("受约束商务看到官方/未知渠道")
}
}
if lx == nil || lx.Counts.Register != 2 || portraitTestMoney(lx.Pay) != "60.60" || lx.Name != "root-channel" || lx.BusinessName() != "lx-business" {
t.Fatalf("渠道树去重、充值或商务展示错误: %+v", lx)
}
scope.Viewer = business.UserScope{All: true}
data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
if err != nil || len(data.Rows) != 5 || data.GlobalCounts.Register != 4 || data.GlobalCounts.Active != 2 || data.GlobalCounts.Pay != 1 || portraitTestMoney(data.GlobalPay) != "2070.60" {
t.Fatalf("超管全局去重或官方/历史渠道遗漏: %+v %v", data, err)
}
}
func TestAnalyticsFiltersKeepHierarchyAndSnapshotNames(t *testing.T) {
db, repo, scope, filter := prepareAnalyticsData(t)
filter.PromoteName = "child"
data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
if err != nil || len(data.Rows) != 1 || data.Rows[0].ID != 100 || data.Rows[0].Counts.Register != 2 || portraitTestMoney(data.Rows[0].Pay) != "60.60" {
t.Fatalf("模糊渠道筛选应扩到同树可见渠道: %+v %v", data, err)
}
if err := db.Exec("UPDATE tab_promote SET register_type=1 WHERE source_db='lx' AND id=101").Error; err != nil {
t.Fatal(err)
}
filter.PromoteName = ""
kind := 1
filter.PromoteType = &kind
data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
if err != nil || len(data.Rows) != 1 || data.Rows[0].ID != 100 || portraitTestMoney(data.Rows[0].Pay) != "20.20" || data.Rows[0].PromoteType == nil || *data.Rows[0].PromoteType != 0 {
t.Fatalf("类型筛选应限制参与渠道,同时显示归根后的当前资料: %+v %v", data, err)
}
filter.PromoteType = nil
filter.GameNames = []string{"历史游戏甲"}
data, err = repo.Aggregate(context.Background(), business.GameAnalytics, scope, filter)
if err != nil || len(data.Rows) != 2 {
t.Fatalf("按游戏快照名筛选错误: %+v %v", data, err)
}
for _, row := range data.Rows {
if row.SourceDB == "lx" && (row.ID != 1 || row.Name != "游戏甲" || row.Counts.Register != 1) {
t.Fatalf("应按报表快照筛选、用当前游戏名展示: %+v", row)
}
}
filter.GameNames = []string{"游戏甲"}
data, err = repo.Aggregate(context.Background(), business.GameAnalytics, scope, filter)
if err != nil || len(data.Rows) != 0 {
t.Fatal("不能用当前游戏表名替代报表快照过滤")
}
}
func TestAnalyticsMissingScopeAndTablesDoNotBroaden(t *testing.T) {
db, repo, scope, filter := prepareAnalyticsData(t)
scope.IncludeSub = false
data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
if err != nil || len(data.Rows) != 2 {
t.Fatal("关闭下级仅保留一级渠道统计")
}
if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil {
t.Fatal(err)
}
data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
if err != nil || data.Available {
t.Fatal("缺身份键表不能返回零汇总")
}
scope.Viewer = business.UserScope{SelfID: 500, OnlySelf: true}
data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
if err != nil || !data.Available || len(data.Rows) != 0 {
t.Fatal("无绑定应为确定空范围")
}
}
+1
View File
@@ -42,6 +42,7 @@ var ProviderSet = wire.NewSet(
databusiness.NewBusinessPortraitRepo,
databusiness.NewChannelPortraitRepo,
databusiness.NewLedgerRepo,
databusiness.NewAnalyticsRepo,
NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
+2
View File
@@ -9,6 +9,8 @@ import (
func Definition() module.Definition {
return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{
Menus: []module.Menu{
{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},
{Name: "sw_RegistrationDetails", Path: "RegistrationDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RegistrationDetails.vue", Title: "注册明细", Icon: "user", Sort: 3},
{Name: "sw_RechargeDetails", Path: "RechargeDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RechargeDetails.vue", Title: "充值明细", Icon: "money", Sort: 2},
+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 != 14 || apis != 58 {
t.Fatalf("admin surface = %d menus/%d APIs, want 14/58", menus, apis)
if menus != 16 || apis != 60 {
t.Fatalf("admin surface = %d menus/%d APIs, want 16/60", menus, apis)
}
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
t.Fatalf("default timed tasks = %d, want 2", len(got))
+2
View File
@@ -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/channelData/list": {group: "渠道统计", description: "查询渠道数据与汇总"},
"GET /sw/gameData/list": {group: "游戏统计", description: "查询游戏数据与汇总"},
"GET /sw/register/list": {group: "注册明细", description: "查询注册明细"},
"POST /sw/register/export": {group: "注册明细", description: "导出注册明细", audit: true},
"GET /sw/spend/list": {group: "充值明细", description: "查询充值明细与成功支付合计"},
+2 -1
View File
@@ -26,6 +26,7 @@ func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlServ
func emptyHandlers() *handler.Set {
return &handler.Set{
Analytics: &handler.Analytics{},
Ledger: &handler.Ledger{},
ChannelPortrait: &handler.ChannelPortrait{},
BusinessPortrait: &handler.BusinessPortrait{},
@@ -106,7 +107,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":241`) {
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":243`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
+38
View File
@@ -0,0 +1,38 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/go-kratos/kratos/v3/errors"
"kra/internal/service/business"
"kra/internal/service/dto"
)
type Analytics struct{ service *business.AnalyticsService }
func NewAnalytics(service *business.AnalyticsService) *Analytics { return &Analytics{service: service} }
func (h *Analytics) Channels(c *gin.Context) {
var req dto.AnalyticsQuery
if c.ShouldBindQuery(&req) != nil {
Fail(c, "统计查询参数不正确")
return
}
data, err := h.service.Channels(c.Request.Context(), &req)
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, data)
}
func (h *Analytics) Games(c *gin.Context) {
var req dto.AnalyticsQuery
if c.ShouldBindQuery(&req) != nil {
Fail(c, "统计查询参数不正确")
return
}
data, err := h.service.Games(c.Request.Context(), &req)
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, data)
}
+1
View File
@@ -15,6 +15,7 @@ var ProviderSet = wire.NewSet(
NewBusinessPortrait,
NewChannelPortrait,
NewLedger,
NewAnalytics,
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
+1
View File
@@ -1,6 +1,7 @@
package handler
type Set struct {
Analytics *Analytics
Ledger *Ledger
ChannelPortrait *ChannelPortrait
BusinessPortrait *BusinessPortrait
+4
View File
@@ -20,6 +20,10 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
return
}
registrations := []func(){
func() {
private.GET("/sw/channelData/list", r.handlers.Analytics.Channels)
private.GET("/sw/gameData/list", r.handlers.Analytics.Games)
},
func() { RegisterLedger(private, r.handlers.Ledger) },
func() { private.GET("/sw/promote/portrait", r.handlers.ChannelPortrait.Portrait) },
func() {
+100
View File
@@ -0,0 +1,100 @@
package business
import (
"context"
"kra/internal/biz/business"
"kra/internal/service/dto"
"strings"
"time"
)
type AnalyticsUsecase interface {
List(context.Context, business.AnalyticsKind, business.AnalyticsFilter, int, int) (*business.AnalyticsPage, error)
}
type AnalyticsService struct{ uc AnalyticsUsecase }
func NewAnalyticsService(uc *business.AnalyticsUsecase) *AnalyticsService {
return &AnalyticsService{uc: uc}
}
func analyticsRequest(req *dto.AnalyticsQuery) (business.AnalyticsFilter, error) {
if req == nil {
return business.AnalyticsFilter{}, business.ErrAnalyticsInput
}
sources, err := normalizePlatforms(req.Sources)
if err != nil {
return business.AnalyticsFilter{}, err
}
f := business.AnalyticsFilter{Sources: sources, GameNames: splitQueryValues(req.GameNames), PromoteName: strings.TrimSpace(req.PromoteName), PromoteType: req.PromoteType, SortField: strings.TrimSpace(req.SortField), Ascending: strings.EqualFold(strings.TrimSpace(req.SortOrder), "asc")}
begin, end := strings.TrimSpace(req.BeginTime), strings.TrimSpace(req.EndTime)
if begin != "" && end != "" {
location := time.FixedZone("Asia/Shanghai", 8*3600)
b, err := time.ParseInLocation("2006-01-02", begin, location)
if err != nil {
return f, business.ErrAnalyticsDate
}
e, err := time.ParseInLocation("2006-01-02", end, location)
if err != nil {
return f, business.ErrAnalyticsDate
}
f.StartDate = &b
f.EndDate = &e
}
return f, nil
}
func analyticsMetrics(row *business.AnalyticsRow) dto.AnalyticsMetrics {
return dto.AnalyticsMetrics{Register: row.Counts.Register, Active: row.Counts.Active, PayUsers: row.Counts.Pay, NewPayUsers: row.Counts.NewPay, TotalPay: row.Pay, PayRate: row.PayRate}
}
func channelAnalyticsRow(row *business.AnalyticsRow, summary bool) *dto.ChannelAnalyticsRow {
result := &dto.ChannelAnalyticsRow{SourceDB: row.SourceDB, SourceDBLabel: business.PlatformName(row.SourceDB), PromoteName: row.Name, PromoteType: row.PromoteType, BusierName: row.BusinessName(), AnalyticsMetrics: analyticsMetrics(row)}
if !summary {
result.PromoteID = &row.ID
}
return result
}
func gameAnalyticsRow(row *business.AnalyticsRow, summary bool) *dto.GameAnalyticsRow {
result := &dto.GameAnalyticsRow{SourceDB: row.SourceDB, SourceDBLabel: business.PlatformName(row.SourceDB), GameName: row.Name, AnalyticsMetrics: analyticsMetrics(row), ARPU: row.ARPU, ARPPU: row.ARPPU}
if !summary {
result.GameID = &row.ID
}
return result
}
func (s *AnalyticsService) Channels(ctx context.Context, req *dto.AnalyticsQuery) (*dto.ChannelAnalyticsPage, error) {
f, err := analyticsRequest(req)
if err != nil {
return nil, err
}
page, err := s.uc.List(ctx, business.ChannelAnalytics, f, req.Page, req.PageSize)
if err != nil {
return nil, err
}
result := &dto.ChannelAnalyticsPage{Rows: []*dto.ChannelAnalyticsRow{}, Total: page.Total, BeginTime: page.Begin, EndTime: page.End, Available: page.Available}
for _, row := range page.Rows {
result.Rows = append(result.Rows, channelAnalyticsRow(row, false))
}
if page.Summary != nil {
result.Summary = channelAnalyticsRow(page.Summary, true)
result.ChannelCount = &page.Total
result.TotalRegisterCount = &page.Summary.Counts.Register
result.TotalPayAmount = &page.Summary.Pay
result.AvgPayRate = &page.Summary.PayRate
}
return result, nil
}
func (s *AnalyticsService) Games(ctx context.Context, req *dto.AnalyticsQuery) (*dto.GameAnalyticsPage, error) {
f, err := analyticsRequest(req)
if err != nil {
return nil, err
}
page, err := s.uc.List(ctx, business.GameAnalytics, f, req.Page, req.PageSize)
if err != nil {
return nil, err
}
result := &dto.GameAnalyticsPage{Rows: []*dto.GameAnalyticsRow{}, Total: page.Total, BeginTime: page.Begin, EndTime: page.End, Available: page.Available}
for _, row := range page.Rows {
result.Rows = append(result.Rows, gameAnalyticsRow(row, false))
}
if page.Summary != nil {
result.Summary = gameAnalyticsRow(page.Summary, true)
}
return result, nil
}
+60
View File
@@ -0,0 +1,60 @@
package dto
type AnalyticsQuery struct {
Page int `form:"pageNum"`
PageSize int `form:"pageSize"`
Sources []string `form:"sourceDb"`
GameNames []string `form:"gameName"`
PromoteType *int `form:"promoteType"`
PromoteName string `form:"promoteName"`
BeginTime string `form:"beginTime"`
EndTime string `form:"endTime"`
SortField string `form:"sortField"`
SortOrder string `form:"sortOrder"`
}
type AnalyticsMetrics struct {
Register int64 `json:"newRegisterCount"`
Active int64 `json:"activeCount"`
PayUsers int64 `json:"payUserCount"`
NewPayUsers int64 `json:"newPayUserCount"`
TotalPay string `json:"totalPay"`
PayRate string `json:"payRate"`
}
type ChannelAnalyticsRow struct {
SourceDB string `json:"sourceDb"`
SourceDBLabel string `json:"sourceDbLabel"`
PromoteID *int64 `json:"promoteId"`
PromoteName string `json:"promoteName"`
PromoteType *int `json:"promoteType"`
BusierName string `json:"busierName"`
AnalyticsMetrics
}
type GameAnalyticsRow struct {
SourceDB string `json:"sourceDb"`
SourceDBLabel string `json:"sourceDbLabel"`
GameID *int64 `json:"gameId"`
GameName string `json:"gameName"`
AnalyticsMetrics
ARPU string `json:"arpu"`
ARPPU string `json:"arppu"`
}
type ChannelAnalyticsPage struct {
Rows []*ChannelAnalyticsRow `json:"rows"`
Total int64 `json:"total"`
ChannelCount *int64 `json:"channelCount"`
TotalRegisterCount *int64 `json:"totalRegisterCount"`
TotalPayAmount *string `json:"totalPayAmount"`
AvgPayRate *string `json:"avgPayRate"`
Summary *ChannelAnalyticsRow `json:"summary"`
BeginTime string `json:"beginTime"`
EndTime string `json:"endTime"`
Available bool `json:"available"`
}
type GameAnalyticsPage struct {
Rows []*GameAnalyticsRow `json:"rows"`
Total int64 `json:"total"`
Summary *GameAnalyticsRow `json:"summary"`
BeginTime string `json:"beginTime"`
EndTime string `json:"endTime"`
Available bool `json:"available"`
}
+1
View File
@@ -24,6 +24,7 @@ var ProviderSet = wire.NewSet(
businessservice.NewBusinessPortraitService,
businessservice.NewChannelPortraitService,
businessservice.NewLedgerService,
businessservice.NewAnalyticsService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,