feat: 按原型更新商务明细与游戏分析并清理临时资料

This commit is contained in:
Yvan
2026-11-04 18:13:03 +08:00
parent 19926d275c
commit f866cac768
41 changed files with 392 additions and 2955 deletions
+13
View File
@@ -79,6 +79,9 @@ type analyticsAggPO struct {
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)
if kind == business.GameAnalytics {
q = q.Where("(r.source_db,r.game_id) IN (?)", approvedGamesQuery(db, scope.Viewer, f.Sources).Select("g.source_db,g.id"))
}
id := "r.game_id"
if kind == business.ChannelAnalytics {
id = "r.promote_id"
@@ -124,6 +127,16 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi
if empty || !available {
return result, nil
}
if kind == business.GameAnalytics {
available, err := approvedGamesAvailable(db)
if err != nil {
return nil, err
}
if !available {
result.Available = false
return result, nil
}
}
for _, table := range []string{"tab_sw_report_day", "tab_sw_report_day_key"} {
available, err := businessTableAvailable(db, table)
if err != nil {
+1
View File
@@ -12,6 +12,7 @@ func prepareAnalyticsData(t *testing.T) (*gorm.DB, business.AnalyticsRepo, busin
t.Helper()
db, _ := prepareChannels(t)
for _, sql := range []string{
"INSERT INTO tab_promote_apply VALUES('hw',100,1,1)",
"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",
+28
View File
@@ -0,0 +1,28 @@
package business
import (
"gorm.io/gorm"
"kra/internal/biz/business"
)
func approvedGamesAvailable(db *gorm.DB) (bool, error) {
for _, table := range []string{"tab_game", "tab_promote", "tab_promote_apply"} {
available, err := businessTableAvailable(db, table)
if err != nil || !available {
return available, err
}
}
return true, nil
}
// 游戏可选范围来自可见渠道的有效申请,不受统计时是否包含下级的开关影响。
// 用平台和 ID 配对,避免同号游戏或其他商务的申请扩大可选范围。
func approvedGamesQuery(db *gorm.DB, viewer business.UserScope, sources []string) *gorm.DB {
promotes := ledgerBasePromotes(db, business.LedgerScope{Viewer: viewer, IncludeSub: true, Unrestricted: viewer.All}, business.LedgerFilter{Sources: sources}, "approved_p").Select("approved_p.source_db,approved_p.id")
applications := db.Table("tab_promote_apply a").Select("a.source_db,a.game_id").Where("a.status=1").Where("(a.source_db,a.promote_id) IN (?)", promotes)
q := db.Table("tab_game g").Where("(g.source_db,g.id) IN (?)", applications)
if len(sources) > 0 {
q = q.Where("g.source_db IN ?", sources)
}
return q
}
@@ -0,0 +1,62 @@
package business
import (
"context"
"testing"
"kra/internal/biz/business"
)
func TestGameOptionsRequireVisibleApprovedApplications(t *testing.T) {
db, _ := prepareChannels(t)
for _, sql := range []string{
"INSERT INTO tab_game VALUES('lx',3,'子渠道游戏'),('lx',4,'未申请包服'),('lx',5,'其他商务游戏'),('lx',6,'待审核游戏')",
"INSERT INTO tab_promote_apply VALUES('lx',104,5,1),('lx',100,6,0)",
} {
if err := db.Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
repo := NewLedgerRepo(testData{db})
ctx := context.Background()
rows, err := repo.Games(ctx, channelTestScope(), nil, "", 100)
if err != nil || len(rows) != 3 {
t.Fatalf("只能返回可见渠道已通过申请的游戏,重复申请不能重复: %+v %v", rows, err)
}
for _, row := range rows {
if row.SourceDB != "lx" || row.ID > 3 {
t.Fatalf("泄漏未申请或其他商务的游戏: %+v", row)
}
}
rows, err = repo.Games(ctx, channelTestScope(), []string{"hw"}, "", 100)
if err != nil || len(rows) != 0 {
t.Fatal("不能借用其他平台同号游戏的申请")
}
rows, err = repo.Games(ctx, business.UserScope{SelfID: 99, OnlySelf: true}, []string{"lx"}, "", 100)
if err != nil || len(rows) != 0 {
t.Fatal("无可见绑定不能退化为全部游戏")
}
rows, err = repo.Games(ctx, channelTestScope(), nil, "子渠道", 1)
if err != nil || len(rows) != 1 || rows[0].ID != 3 {
t.Fatal("应支持已申请游戏的关键词与数量筛选")
}
if err := db.Exec("DROP TABLE tab_promote_apply").Error; err != nil {
t.Fatal(err)
}
if _, err := repo.Games(ctx, channelTestScope(), nil, "", 100); err != business.ErrGameOptionsUnavailable {
t.Fatal("缺申请表不能开放所有游戏")
}
}
func TestGameReportsRejectUnapprovedGamesEvenWithVisibleHistory(t *testing.T) {
db, repo, scope, filter := prepareAnalyticsData(t)
if err := db.Exec("UPDATE tab_promote_apply SET status=0 WHERE source_db='lx' AND game_id=1").Error; err != nil {
t.Fatal(err)
}
filter.Sources = []string{"lx"}
filter.GameNames = []string{"历史游戏甲"}
rows, err := repo.Aggregate(context.Background(), business.GameAnalytics, scope, filter)
if err != nil || !rows.Available || len(rows.Rows) != 0 {
t.Fatalf("直接指定未申请游戏也不能返回统计: %+v %v", rows, err)
}
}
+45 -15
View File
@@ -32,11 +32,8 @@ func ledgerPromotes(db *gorm.DB, scope business.LedgerScope, filter business.Led
return q
}
hits := ledgerBasePromotes(db, scope, filter, "hit")
exact := "TRIM(hit.account) = ? COLLATE BINARY"
if db.Dialector.Name() == "mysql" {
exact = "BINARY TRIM(hit.account) = ?"
}
hits = hits.Where(exact, filter.PromoteAccount).Where("hit.source_db=" + alias + ".source_db")
keyword := strings.ToLower(strings.TrimSpace(filter.PromoteAccount))
hits = hits.Where("(INSTR(LOWER(hit.account),?) > 0 OR INSTR(LOWER(hit.nickname),?) > 0)", keyword, keyword).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 + "))")
@@ -232,7 +229,8 @@ func spendQuery(db *gorm.DB, scope business.LedgerScope, filter business.LedgerF
q := ledgerScopeQuery(db, scope, filter, "tab_spend").Where("r.pay_time BETWEEN ? AND ?", filter.BeginUnix, filter.EndUnix)
if successOnly {
q = q.Where("r.pay_status = 1")
} else if filter.PayStatus != nil {
}
if filter.PayStatus != nil {
q = q.Where("r.pay_status = ?", *filter.PayStatus)
}
if len(filter.GameNames) > 0 {
@@ -353,11 +351,13 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
SourceDB string `gorm:"column:source_db"`
PlatformBusierID uint64
PlatformBusierAccount, Username string
UserID uint
IDPath string `gorm:"column:id_path"`
}
names := map[business.PlatformKey]bindName{}
for start := 0; start < len(pairs); start += 300 {
var rows []bindName
q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).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")
q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,b.user_id,u.username,u.id_path").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id")
if err := q.Scan(&rows).Error; err != nil {
return business.ErrStorage
}
@@ -368,6 +368,31 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
}
}
}
ancestorIDs := map[uint]bool{}
paths := map[business.PlatformKey][]uint{}
for key, binding := range names {
for _, part := range strings.Split(binding.IDPath, ",") {
id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 32)
if err == nil && id > 0 && uint(id) != binding.UserID {
paths[key] = append(paths[key], uint(id))
ancestorIDs[uint(id)] = true
}
}
}
ids := make([]uint, 0, len(ancestorIDs))
for id := range ancestorIDs {
ids = append(ids, id)
}
ancestors := map[uint]string{}
for start := 0; start < len(ids); start += 300 {
var rows []accountPO
if err := db.Select("id,username").Where("id IN ? AND level > 0", ids[start:min(start+300, len(ids))]).Find(&rows).Error; err != nil {
return business.ErrStorage
}
for _, row := range rows {
ancestors[row.ID] = row.Username
}
}
for _, target := range targets {
n := target.Names
if n.PromoteID == nil || *n.PromoteID < 0 {
@@ -380,33 +405,38 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
if binding, ok := names[business.PlatformKey{SourceDB: c.SourceDB, BusierID: c.BusierID}]; ok {
n.BindingAccount = binding.PlatformBusierAccount
n.BindingUserName = binding.Username
for _, id := range paths[business.PlatformKey{SourceDB: c.SourceDB, BusierID: c.BusierID}] {
if name := strings.TrimSpace(ancestors[id]); name != "" {
n.BusinessPath = append(n.BusinessPath, name)
}
}
if name := strings.TrimSpace(binding.Username); name != "" {
n.BusinessPath = append(n.BusinessPath, name)
}
}
}
}
return nil
}
func (r *ledgerRepo) Games(ctx context.Context, sources []string, keyword string, limit int) ([]*business.GameOption, error) {
func (r *ledgerRepo) Games(ctx context.Context, viewer business.UserScope, sources []string, keyword string, limit int) ([]*business.GameOption, error) {
db := r.data.DB().WithContext(ctx)
available, err := businessTableAvailable(db, "tab_game")
available, err := approvedGamesAvailable(db)
if err != nil {
return nil, err
}
if !available {
return nil, business.ErrGameOptionsUnavailable
}
q := db.Table("tab_game")
if len(sources) > 0 {
q = q.Where("source_db IN ?", sources)
}
q := approvedGamesQuery(db, viewer, sources)
if keyword != "" {
q = q.Where("game_name LIKE ?", "%"+keyword+"%")
q = q.Where("INSTR(LOWER(g.game_name),?) > 0", strings.ToLower(keyword))
}
var rows []struct {
ID uint64
SourceDB string `gorm:"column:source_db"`
GameName string
}
if err := q.Select("id,source_db,game_name").Order("game_name,id,source_db").Limit(limit).Scan(&rows).Error; err != nil {
if err := q.Select("g.id,g.source_db,g.game_name").Distinct().Order("g.game_name,g.id,g.source_db").Limit(limit).Scan(&rows).Error; err != nil {
return nil, business.ErrStorage
}
result := make([]*business.GameOption, 0, len(rows))
+29 -7
View File
@@ -40,7 +40,7 @@ func TestRegistrationScopePuidAndUnboundedPlayerPay(t *testing.T) {
first = row
}
}
if first == nil || first.TotalPayAmount == nil || portraitTestMoney(*first.TotalPayAmount) != "710.10" || first.FirstGameName != "历史游戏名" || first.DisplayBusiness() != "lx-business(leaf)" {
if first == nil || first.TotalPayAmount == nil || portraitTestMoney(*first.TotalPayAmount) != "710.10" || first.FirstGameName != "历史游戏名" || first.DisplayBusiness() != "parent→branch→leaf" {
t.Fatalf("累计充值不能受本次时间/渠道限制,且需保留游戏快照: %+v", first)
}
filter.SortField = "totalPayAmount"
@@ -59,19 +59,19 @@ func TestRegistrationScopePuidAndUnboundedPlayerPay(t *testing.T) {
if err != nil || page.Total != 2 {
t.Fatal("精确子渠道账号应扩到同一一级树内的可见渠道")
}
filter.PromoteAccount = "CHILD-channel"
filter.PromoteAccount = "CHILD-chan"
page, err = repo.Registrations(ctx, scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 0 {
t.Fatal("渠道账号应保持区分大小写的精确匹配")
if err != nil || page.Total != 2 {
t.Fatal("渠道应支持不区分大小写的模糊匹配")
}
}
func TestSpendSummaryIgnoresSelectedFailureStatusAndKeepsSnapshot(t *testing.T) {
func TestSpendSummaryRespectsSelectedStatusAndKeepsSnapshot(t *testing.T) {
_, repo, scope, filter := prepareLedgerData(t)
status := 0
filter.PayStatus = &status
page, err := repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 1 || page.Rows[0].ID != 904 || portraitTestMoney(*page.TotalPayAmount) != "80.80" {
t.Fatalf("失败列表与成功支付合计不能使用同一个状态条件: %+v %v", page, err)
if err != nil || page.Total != 1 || page.Rows[0].ID != 904 || portraitTestMoney(*page.TotalPayAmount) != "0.00" {
t.Fatalf("失败状态筛选下不能汇总筛选外成功订单: %+v %v", page, err)
}
status = 2
page, err = repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
@@ -125,3 +125,25 @@ func TestLedgerLargeTreeFilterDoesNotFallBackToAll(t *testing.T) {
t.Fatal("缺渠道范围表不能把限制解释为全量")
}
}
func TestLedgerChannelNicknameAndLiteralFuzzyFilter(t *testing.T) {
db, repo, scope, filter := prepareLedgerData(t)
if err := db.Exec("UPDATE tab_promote SET nickname='推广公会' WHERE source_db='lx' AND id=100").Error; err != nil {
t.Fatal(err)
}
filter.PromoteAccount = "公会"
page, err := repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 4 {
t.Fatalf("昵称片段应命中渠道及可见下级: %+v %v", page, err)
}
filter.PromoteAccount = "%"
page, err = repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 0 {
t.Fatal("模糊输入中的百分号应按文字匹配")
}
filter.PromoteAccount = "other"
page, err = repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 0 {
t.Fatal("模糊搜索不得扩大商务范围")
}
}
+5 -4
View File
@@ -17,6 +17,7 @@ func NewServerAnalysisRepo(data Provider) business.ServerAnalysisRepo {
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)
q = q.Where("(r.source_db,r.game_id) IN (?)", approvedGamesQuery(db, scope.Viewer, f.Sources).Select("g.source_db,g.id"))
if f.ServerNum != "" {
q = q.Where("r.server_num=?", f.ServerNum)
}
@@ -44,7 +45,7 @@ func (r *serverAnalysisRepo) Aggregate(ctx context.Context, scope business.Analy
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"} {
for _, table := range []string{"tab_sw_report_server_day", "tab_sw_report_server_day_key", "tab_game_server", "tab_game", "tab_promote_apply"} {
available, err := businessTableAvailable(db, table)
if err != nil {
return nil, err
@@ -144,9 +145,9 @@ func fillServerAnalysisNames(db *gorm.DB, rows map[business.ServerAnalysisKey]*b
}
return nil
}
func (r *serverAnalysisRepo) Servers(ctx context.Context, sources, games []string, keyword string, limit int) ([]*business.ServerOption, error) {
func (r *serverAnalysisRepo) Servers(ctx context.Context, viewer business.UserScope, 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"} {
for _, table := range []string{"tab_game", "tab_game_server", "tab_promote", "tab_promote_apply"} {
available, err := businessTableAvailable(db, table)
if err != nil {
return nil, err
@@ -158,7 +159,7 @@ func (r *serverAnalysisRepo) Servers(ctx context.Context, sources, games []strin
// MySQL 5.7 不会将相关 EXISTS 展开为半连接,导致扫描全部区服。
// 多列 IN 让优化器先按游戏名索引定位,再使用区服的 game_id 索引,
// 同时保留平台匹配和重复游戏资料不扩增结果的语义。
gamesQuery := db.Table("tab_game g").Select("g.source_db,g.id").Where("g.game_name IN ?", games)
gamesQuery := approvedGamesQuery(db, viewer, sources).Select("g.source_db,g.id").Where("g.game_name IN ?", games)
q := db.Table("tab_game_server s").Where("(s.source_db,s.game_id) IN (?)", gamesQuery)
if len(sources) > 0 {
q = q.Where("s.source_db IN ?", sources)
+21 -4
View File
@@ -9,6 +9,7 @@ import (
func TestServerAnalysisStorageScopeSnapshotAndPreference(t *testing.T) {
db, _ := prepareChannels(t)
for _, sql := range []string{
"INSERT INTO tab_promote_apply VALUES('hw',100,1,1)",
"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))",
@@ -68,7 +69,7 @@ func TestServerAnalysisStorageScopeSnapshotAndPreference(t *testing.T) {
if err != nil || len(p.Rows) != 1 {
t.Fatal("区服编号筛选错误")
}
options, err := r.Servers(ctx, []string{"lx"}, []string{"游戏甲"}, "一区", 50)
options, err := r.Servers(ctx, s.Viewer, []string{"lx"}, []string{"游戏甲"}, "一区", 50)
if err != nil || len(options) != 1 || options[0].Number != "001" || options[0].Name != "龙翔一区" {
t.Fatal("选项应按当前游戏名、同平台游戏关联及区服名称筛选")
}
@@ -77,6 +78,18 @@ func TestServerAnalysisStorageScopeSnapshotAndPreference(t *testing.T) {
if err != nil || len(p.Rows) != 0 {
t.Fatal("报表必须按快照游戏名筛选")
}
if err := db.Exec("UPDATE tab_promote_apply SET status=0 WHERE source_db='lx' AND game_id=1").Error; err != nil {
t.Fatal(err)
}
options, err = r.Servers(ctx, s.Viewer, []string{"lx"}, []string{"游戏甲"}, "", 50)
if err != nil || len(options) != 0 {
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)
}
@@ -89,6 +102,10 @@ func TestServerAnalysisStorageScopeSnapshotAndPreference(t *testing.T) {
func TestServerOptionsPreservePlatformFilterAndDoNotDuplicateMatches(t *testing.T) {
db := openTestDB(t)
for _, sql := range []string{
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT)",
"INSERT INTO tab_promote VALUES(100,'lx'),(100,'hw')",
"CREATE TABLE tab_promote_apply(source_db TEXT,promote_id INTEGER,game_id INTEGER,status INTEGER)",
"INSERT INTO tab_promote_apply VALUES('lx',100,1,1),('lx',100,2,1),('hw',100,1,1)",
"CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
"INSERT INTO tab_game VALUES(1,'lx','甲'),(1,'lx','甲'),(1,'hw','乙'),(2,'lx','乙')",
"CREATE TABLE tab_game_server(id INTEGER,source_db TEXT,game_id INTEGER,server_num TEXT,server_name TEXT,start_time INTEGER)",
@@ -99,15 +116,15 @@ func TestServerOptionsPreservePlatformFilterAndDoNotDuplicateMatches(t *testing.
}
}
repo := NewServerAnalysisRepo(testData{db})
rows, err := repo.Servers(context.Background(), nil, []string{"甲"}, "", 100)
rows, err := repo.Servers(context.Background(), business.UserScope{All: true}, nil, []string{"甲"}, "", 100)
if err != nil || len(rows) != 2 || rows[0].Name != "一区" || rows[1].Number != "002" {
t.Fatalf("相同游戏资料不能扩增区服,异平台同号不能匹配: %+v %v", rows, err)
}
rows, err = repo.Servers(context.Background(), []string{"lx"}, []string{"甲", "乙"}, "区", 1)
rows, err = repo.Servers(context.Background(), business.UserScope{All: true}, []string{"lx"}, []string{"甲", "乙"}, "区", 1)
if err != nil || len(rows) != 1 || rows[0].Name != "一区" {
t.Fatalf("名称筛选、排序或数量限制改变: %+v %v", rows, err)
}
rows, err = repo.Servers(context.Background(), []string{"hw"}, []string{"甲"}, "", 100)
rows, err = repo.Servers(context.Background(), business.UserScope{All: true}, []string{"hw"}, []string{"甲"}, "", 100)
if err != nil || len(rows) != 0 {
t.Fatalf("平台筛选被忽略: %+v %v", rows, err)
}