From 1aa15d3dc84c0f08d98111319649f7b700e34c70 Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Thu, 5 Nov 2026 10:57:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E5=90=88=E8=AE=A1=E7=AD=9B=E9=80=89=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=8F=8A=E6=9C=89=E6=95=88=E7=AB=AF=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E9=81=97=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/analytics_http_test.go | 35 +++++++++ internal/biz/business/analytics.go | 38 ++++++++-- internal/biz/business/analytics_test.go | 24 ++++++ internal/biz/business/channel.go | 17 ++++- internal/data/business/analytics.go | 73 +++++++++++++++++-- internal/data/business/analytics_test.go | 64 ++++++++++++++++ internal/data/business/channel.go | 40 ++++++++-- internal/data/business/channel_test.go | 38 ++++++++++ internal/server/handler/analytics.go | 11 +++ internal/service/business/analytics.go | 16 ++-- internal/service/business/analytics_excel.go | 46 ++++++++++++ internal/service/business/channel.go | 24 +++++- internal/service/business/date_range_test.go | 17 ++++- internal/service/dto/analytics.go | 3 + internal/service/dto/channel.go | 45 ++++++++---- web/src/api/analytics.js | 1 + web/src/view/sw/ChannelList.vue | 40 +++++++--- .../view/sw/components/BusinessManagement.vue | 4 +- web/src/view/sw/components/MyGoalCard.vue | 6 +- .../view/sw/components/ReportAnalytics.vue | 73 ++++++++++++------- web/src/view/sw/performance-display.js | 6 +- web/src/view/sw/performance-display.test.js | 12 ++- 22 files changed, 545 insertions(+), 88 deletions(-) create mode 100644 internal/service/business/analytics_excel.go diff --git a/cmd/analytics_http_test.go b/cmd/analytics_http_test.go index 9a3d52f..76dbaf0 100644 --- a/cmd/analytics_http_test.go +++ b/cmd/analytics_http_test.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" "path/filepath" @@ -10,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" + "github.com/xuri/excelize/v2" "gorm.io/gorm" businessbiz "kra/internal/biz/business" "kra/internal/biz/system" @@ -120,6 +123,38 @@ func TestChannelAndGameAnalyticsHTTP(t *testing.T) { if first["busierName"] != "SDK账号" || first["newRegisterCount"] != float64(1) { t.Fatal("渠道树去重或商务展示未保留") } + t.Run("export includes every authorized row and all totals", func(t *testing.T) { + req, _ := http.NewRequest("GET", server.URL+"/sw/channelData/list?export=true&sourceDb=lx,hw&beginTime=2026-11-04&endTime=2026-11-04&pageSize=1&pageNum=99", nil) + req.Header.Set("X-Test-User", "7") + res, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if res.Header.Get("success") != "true" { + t.Fatalf("export failed: %s", body) + } + book, err := excelize.OpenReader(bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer book.Close() + rows, err := book.GetRows("渠道数据统计") + if err != nil { + t.Fatal(err) + } + if len(rows) != 4 || rows[1][0] != "龙翔" || rows[2][0] != "龙翔" || rows[3][0] != "全部" || rows[3][1] != "合计(有效端 0)" || rows[3][9] != "66.67%" { + t.Fatalf("导出必须包含可见全量及完整合计,不受分页影响: %+v", rows) + } + amount, _ := book.GetCellValue("渠道数据统计", "G4", excelize.Options{RawCellValue: true}) + if amount != "60.00" { + t.Fatalf("export amount=%s", amount) + } + }) data = call(1, "channelData", "sourceDb=lx,hw", 0) if data["total"] != float64(5) || data["totalRegisterCount"] != float64(3) || data["totalPayAmount"] != "112.00" || data["avgPayRate"] != "75.00" { t.Fatalf("超管应全局去重并包含官方/未知渠道: %+v", data) diff --git a/internal/biz/business/analytics.go b/internal/biz/business/analytics.go index 50d70cb..1b30d84 100644 --- a/internal/biz/business/analytics.go +++ b/internal/biz/business/analytics.go @@ -30,6 +30,7 @@ type AnalyticsFilter struct { Sources, GameNames []string PromoteName string PromoteType *int + HasRegister, HasPay *bool StartDate, EndDate *time.Time Begin, End, SortField string Ascending bool @@ -113,6 +114,12 @@ func analyticsValues(row *AnalyticsRow) error { return nil } func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter AnalyticsFilter, page, size int) (*AnalyticsPage, error) { + return uc.list(ctx, kind, filter, page, size, false) +} +func (uc *AnalyticsUsecase) ListAll(ctx context.Context, kind AnalyticsKind, filter AnalyticsFilter) (*AnalyticsPage, error) { + return uc.list(ctx, kind, filter, 1, 0, true) +} +func (uc *AnalyticsUsecase) list(ctx context.Context, kind AnalyticsKind, filter AnalyticsFilter, page, size int, all bool) (*AnalyticsPage, error) { actor, account, err := uc.accounts.actor(ctx) if err != nil { return nil, err @@ -130,16 +137,23 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter filter.PromoteName = "" filter.PromoteType = nil } - if filter.StartDate == nil || filter.EndDate == nil { + if filter.EndDate == nil || kind == GameAnalytics && filter.StartDate == 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 kind == GameAnalytics && filter.StartDate == nil { + filter.StartDate = &day + } + if filter.EndDate == nil { + filter.EndDate = &day + } } - if filter.EndDate.Before(*filter.StartDate) { + if filter.StartDate != nil && filter.EndDate.Before(*filter.StartDate) { return nil, ErrAnalyticsDateOrder } - filter.Begin = filter.StartDate.Format("2006-01-02") + filter.Begin = "" + if filter.StartDate != nil { + 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} scope := AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup} @@ -221,6 +235,18 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter return nil, err } out.Total = int64(len(data.Rows)) + if all { + if out.Total > 10000 { + return nil, ErrLedgerExportLimit + } + out.Rows = data.Rows + if !namesBeforeSort { + if err := fillNames(out.Rows); err != nil { + return nil, err + } + } + return out, nil + } if page < 1 { page = 1 } @@ -243,7 +269,7 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter return out, nil } func sortAnalytics(rows []*AnalyticsRow, kind AnalyticsKind, field string, ascending bool) error { - if kind == GameAnalytics && field == "" { + if field == "" { sort.SliceStable(rows, func(i, j int) bool { if rows[i].SourceDB != rows[j].SourceDB { return rows[i].SourceDB < rows[j].SourceDB diff --git a/internal/biz/business/analytics_test.go b/internal/biz/business/analytics_test.go index d4f0df6..ab915d5 100644 --- a/internal/biz/business/analytics_test.go +++ b/internal/biz/business/analytics_test.go @@ -81,6 +81,30 @@ func TestChannelAnalyticsSummaryDependsOnViewer(t *testing.T) { t.Fatal("超管应使用筛选范围内全局去重汇总") } } + +func TestChannelAnalyticsAllPagesSummaryAndOpenDateRange(t *testing.T) { + uc, repo := analyticsFixture() + repo.data.EffectiveCount = 3 + for _, pageNum := range []int{1, 2, 8} { + page, err := uc.List(accountContext(7, 901), ChannelAnalytics, AnalyticsFilter{}, pageNum, 1) + if err != nil || page.Total != 2 || page.Summary.Counts.Register != 5 || page.Summary.Counts.Active != 10 || page.Summary.Counts.Pay != 3 || page.Summary.Counts.NewPay != 2 || page.Summary.Pay != "50.00" || page.Summary.PayRate != "30.00" || page.EffectiveCount != 3 { + t.Fatalf("翻页不能改变完整合计: page=%d %+v %v", pageNum, page, err) + } + if page.Begin != "" || page.End != "2026-11-04" || repo.filter.StartDate != nil { + t.Fatalf("渠道统计缺省应从最早数据统计至今天: %+v", repo.filter) + } + } + for _, sample := range []struct { + field string + asc bool + source string + }{{"totalPay", true, "lx"}, {"totalPay", false, "hw"}, {"", false, "hw"}} { + page, err := uc.List(accountContext(7, 901), ChannelAnalytics, AnalyticsFilter{SortField: sample.field, Ascending: sample.asc}, 1, 1) + if err != nil || page.Rows[0].SourceDB != sample.source { + t.Fatalf("取消排序应恢复稳定原始顺序: %+v %v", page, err) + } + } +} func TestAnalyticsSortAndMissingData(t *testing.T) { uc, r := analyticsFixture() r.data.Rows[0].Pay = "9007199254740993.00" diff --git a/internal/biz/business/channel.go b/internal/biz/business/channel.go index 83c2f68..8c24f5c 100644 --- a/internal/biz/business/channel.go +++ b/internal/biz/business/channel.go @@ -34,6 +34,8 @@ type ChannelKey struct { type Channel struct { ChannelKey Account, Nickname, ParentName, RealName string + Phone string + SettlementDays *int Level, RegisterType, Status, LockStatus *int ParentID, TopPromoteID, BusierID uint64 BusierUserID *uint @@ -75,12 +77,21 @@ type ChannelFilter struct { Sources []string BusierUserID *uint BusierAccount, Keyword string + Phone, SortField string + SettlementDays *int + CreatedBegin, CreatedEnd *time.Time + LoginBegin, LoginEnd *time.Time + Ascending bool Level, RegisterType, Status, LockStatus *int } +type ChannelStatusCounts struct { + Normal, Locked, Terminated int64 +} type ChannelPage struct { - Rows []*Channel - Total int64 - Available bool + Rows []*Channel + Total int64 + Available bool + StatusCounts *ChannelStatusCounts } type ChannelPlayer struct { ChannelKey diff --git a/internal/data/business/analytics.go b/internal/data/business/analytics.go index b1bd6c3..816c127 100644 --- a/internal/data/business/analytics.go +++ b/internal/data/business/analytics.go @@ -2,6 +2,7 @@ package business import ( "context" + "math/big" "sort" "strings" "time" @@ -59,7 +60,13 @@ func prepareAnalytics(db *gorm.DB, scope business.AnalyticsScope, f business.Ana 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) + q := db.Table(table+" r").Where("r.dedup_mode = ? AND r.game_id > 0", mode) + if f.Begin != "" { + q = q.Where("r.stat_date >= ?", f.Begin) + } + if f.End != "" { + q = q.Where("r.stat_date <= ?", f.End) + } if len(f.Sources) > 0 { q = q.Where("r.source_db IN ?", f.Sources) } @@ -165,6 +172,7 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi var global []portraitCountPO globalPay := "0" withGlobal := kind == business.ChannelAnalytics && scope.Viewer.All + metricFiltered := kind == business.ChannelAnalytics && (f.HasRegister != nil || f.HasPay != nil) group, queryContext := errgroup.WithContext(ctx) // 每个请求最多两个并行查询,复用同一存储客户端;结果读取完后再组装 DO。 group.SetLimit(2) @@ -183,7 +191,7 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi } return nil }) - if withGlobal { + if withGlobal && !metricFiltered { group.Go(func() error { q := analyticsQuery(queryDB, scope, f, "tab_sw_report_day_key", mode) if err := q.Select("r.metric,COUNT(DISTINCT " + portraitIdentity(queryDB, scope.IDCardDedup) + ") AS count").Group("r.metric").Scan(&global).Error; err != nil { @@ -208,6 +216,23 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi putAnalyticsCount(&rowFor(po).Counts, po.Metric, po.Count) } } + // Keep visible channels with no events in the selected period so that + // "no registration / no recharge" can find genuinely inactive channels. + if kind == business.ChannelAnalytics && len(f.GameNames) == 0 { + 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" + } + var channels []analyticsAggPO + if err := analyticsPromotes(db, scope, f, "p").Select("DISTINCT p.source_db," + root + " AS entity_id").Scan(&channels).Error; err != nil { + return nil, business.ErrStorage + } + for _, po := range channels { + if po.ID != nil { + rowFor(po) + } + } + } if kind == business.ChannelAnalytics && !scope.Viewer.All { if err := filterAnalyticsRows(db, scope, f, rowMap); err != nil { return nil, err @@ -218,6 +243,33 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi return nil, err } } + if metricFiltered { + for key, row := range rowMap { + pay, ok := new(big.Rat).SetString(row.Pay) + if !ok { + return nil, business.ErrStorage + } + if f.HasRegister != nil && (row.Counts.Register > 0) != *f.HasRegister || f.HasPay != nil && (pay.Sign() > 0) != *f.HasPay { + delete(rowMap, key) + } + } + // Recompute the administrator's distinct-player totals over matched + // channel trees, before pagination. Summing per-row ratios is incorrect. + if withGlobal && len(rowMap) > 0 { + keys := make([]business.AnalyticsKey, 0, len(rowMap)) + for key := range rowMap { + keys = append(keys, key) + } + q, id := analyticsRoot(db, kind, scope, f, "tab_sw_report_day_key", mode, false) + if err := q.Where("(r.source_db,"+id+") IN ?", analyticsPairs(keys)).Select("r.metric,COUNT(DISTINCT " + portraitIdentity(db, scope.IDCardDedup) + ") AS count").Group("r.metric").Scan(&global).Error; err != nil { + return nil, business.ErrStorage + } + q, id = analyticsRoot(db, kind, scope, f, "tab_sw_report_day", 0, true) + if err := q.Where("(r.source_db,"+id+") IN ?", analyticsPairs(keys)).Select("COALESCE(SUM(r.total_pay),0)").Scan(&globalPay).Error; err != nil { + return nil, business.ErrStorage + } + } + } if withGlobal { totals := toPortraitCounts(global) result.GlobalCounts, result.GlobalPay = &totals, globalPay @@ -227,15 +279,26 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi } if kind == business.ChannelAnalytics { location := time.FixedZone("Asia/Shanghai", 8*3600) - begin, _ := time.ParseInLocation("2006-01-02", f.Begin, location) - end, _ := time.ParseInLocation("2006-01-02", f.End, location) + 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" + } keys := []business.AnalyticsKey{} for key := range rowMap { keys = append(keys, key) } for start := 0; start < len(keys); start += 300 { var count int64 - if err := db.Table("sys_business_channel_effectiveness e").Joins("JOIN sys_business_effectiveness_state s ON s.id=1 AND s.rule_key=e.rule_key").Where("(e.source_db,e.promote_id) IN ? AND e.qualified_at>=? AND e.qualified_at= ?", begin.Unix()) + } + if f.End != "" { + end, _ := time.ParseInLocation("2006-01-02", f.End, location) + q = q.Where("e.qualified_at < ?", end.AddDate(0, 0, 1).Unix()) + } + if err := q.Count(&count).Error; err != nil { return nil, business.ErrStorage } result.EffectiveCount += count diff --git a/internal/data/business/analytics_test.go b/internal/data/business/analytics_test.go index 6385aaa..e695e05 100644 --- a/internal/data/business/analytics_test.go +++ b/internal/data/business/analytics_test.go @@ -3,6 +3,7 @@ package business import ( "context" "testing" + "time" "gorm.io/gorm" "kra/internal/biz/business" @@ -59,6 +60,69 @@ func TestAnalyticsChannelTreeAndAdminOfficialRows(t *testing.T) { t.Fatalf("超管全局去重或官方/历史渠道遗漏: %+v %v", data, err) } } + +func TestAnalyticsFiltersIncludeIdleChannelsAndRecalculateTotals(t *testing.T) { + db, repo, scope, filter := prepareAnalyticsData(t) + if err := db.Exec("INSERT INTO tab_promote(id,source_db,account,promote_level,parent_id,top_promote_id,busier_id) VALUES(200,'lx','idle',1,0,0,14)").Error; err != nil { + t.Fatal(err) + } + yes, no := true, false + filter.HasRegister, filter.HasPay = &no, &no + for _, viewer := range []business.UserScope{scope.Viewer, {All: true}} { + scope.Viewer = viewer + data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) + if err != nil { + t.Fatal(err) + } + found := false + for _, row := range data.Rows { + if row.Counts.Register != 0 || portraitTestMoney(row.Pay) != "0.00" { + t.Fatalf("筛选后的每行必须同时满足两个条件: %+v", row) + } + found = found || row.SourceDB == "lx" && row.ID == 200 + } + if !found { + t.Fatal("整个期间没有事件的可见渠道不能遗漏") + } + if viewer.All && (data.GlobalCounts.Register != 0 || portraitTestMoney(data.GlobalPay) != "0.00") { + t.Fatalf("管理员合计未跟随过滤: %+v", data) + } + } + filter.HasRegister, filter.HasPay = &yes, &yes + filter.Sources, filter.PromoteName = []string{"lx"}, "root-channel" + data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) + if err != nil || len(data.Rows) != 1 || portraitTestMoney(data.GlobalPay) != "60.60" || data.GlobalCounts.Register != data.Rows[0].Counts.Register { + t.Fatalf("平台、名称及指标筛选须共同约束列表与全量合计: %+v %v", data, err) + } +} + +func TestAnalyticsEffectiveCountIncludesQualifiedDescendants(t *testing.T) { + db, repo, scope, filter := prepareAnalyticsData(t) + qualified := time.Date(2026, 11, 2, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).Unix() + if err := db.Exec("UPDATE sys_business_effectiveness_state SET rule_key='audit' WHERE id=1").Error; err != nil { + t.Fatal(err) + } + for _, key := range []business.ChannelKey{{SourceDB: "lx", ID: 100}, {SourceDB: "lx", ID: 101}, {SourceDB: "yq", ID: 100}} { + if err := db.Create(&effectivenessPO{RuleKey: "audit", SourceDB: key.SourceDB, PromoteID: key.ID, QualifiedAt: &qualified, WindowPay: "3000"}).Error; err != nil { + t.Fatal(err) + } + } + filter.PromoteName = "root-channel" + data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) + if err != nil || len(data.Rows) != 1 || data.EffectiveCount != 2 { + t.Fatalf("合并渠道树展示仍须计入下级达标,且不能混入权限外同号渠道: %+v %v", data, err) + } + scope.IncludeSub = false + data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) + if err != nil || data.EffectiveCount != 1 { + t.Fatalf("关闭包含下级后只统计自身: %+v %v", data, err) + } + filter.Begin = "2026-11-03" + data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) + if err != nil || data.EffectiveCount != 0 { + t.Fatalf("有效端必须按达标时间归属统计区间: %+v %v", data, err) + } +} func TestAnalyticsFiltersKeepHierarchyAndSnapshotNames(t *testing.T) { db, repo, scope, filter := prepareAnalyticsData(t) filter.PromoteName = "child" diff --git a/internal/data/business/channel.go b/internal/data/business/channel.go index b7f7f07..ae952e7 100644 --- a/internal/data/business/channel.go +++ b/internal/data/business/channel.go @@ -4,6 +4,7 @@ import ( "context" "errors" "strings" + "time" "gorm.io/gorm" "kra/internal/biz/business" @@ -18,11 +19,13 @@ type channelPO struct { ParentID, TopPromoteID, BusierID uint64 CreateTime *int64 LastLoginTime *int64 `gorm:"column:last_login_time"` + MobilePhone string + SettlementDayPeriod *int } func (channelPO) TableName() string { return "tab_promote" } func toBizChannel(p channelPO) *business.Channel { - return &business.Channel{ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.ID}, Account: p.Account, Nickname: p.Nickname, ParentName: p.ParentName, RealName: p.RealName, Level: p.PromoteLevel, RegisterType: p.RegisterType, Status: p.Status, LockStatus: p.LockStatus, ParentID: p.ParentID, TopPromoteID: p.TopPromoteID, BusierID: p.BusierID, CreatedUnix: p.CreateTime, LastLoginUnix: p.LastLoginTime, QualifiedAt: p.QualifiedAt} + return &business.Channel{ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.ID}, Account: p.Account, Nickname: p.Nickname, ParentName: p.ParentName, RealName: p.RealName, Level: p.PromoteLevel, RegisterType: p.RegisterType, Status: p.Status, LockStatus: p.LockStatus, ParentID: p.ParentID, TopPromoteID: p.TopPromoteID, BusierID: p.BusierID, CreatedUnix: p.CreateTime, LastLoginUnix: p.LastLoginTime, QualifiedAt: p.QualifiedAt, Phone: p.MobilePhone, SettlementDays: p.SettlementDayPeriod} } type channelRepo struct{ data Provider } @@ -58,7 +61,7 @@ func (r *channelRepo) Bindings(ctx context.Context, scope business.UserScope, so } func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter business.ChannelFilter, options ...business.ListOption) (*business.ChannelPage, error) { db := r.data.DB().WithContext(ctx) - page := &business.ChannelPage{Rows: []*business.Channel{}, Available: true} + page := &business.ChannelPage{Rows: []*business.Channel{}, Available: true, StatusCounts: &business.ChannelStatusCounts{}} bindings := channelBindingQuery(db, scope, filter) constrain := !scope.All || filter.BusierUserID != nil || filter.BusierAccount != "" if constrain { @@ -89,6 +92,26 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter keyword := strings.ToLower(strings.TrimSpace(filter.Keyword)) q = q.Where("INSTR(LOWER(p.account),?)>0 OR INSTR(LOWER(p.nickname),?)>0 OR INSTR(LOWER(p.real_name),?)>0", keyword, keyword, keyword) } + if filter.Phone != "" { + q = q.Where("INSTR(p.mobile_phone,?) > 0", filter.Phone) + } + if filter.SettlementDays != nil { + q = q.Where("p.settlement_day_period = ?", *filter.SettlementDays) + } + for _, period := range []struct { + column string + begin, end *time.Time + }{ + {"p.create_time", filter.CreatedBegin, filter.CreatedEnd}, + {"p.last_login_time", filter.LoginBegin, filter.LoginEnd}, + } { + if period.begin != nil { + q = q.Where(period.column+" >= ?", period.begin.Unix()) + } + if period.end != nil { + q = q.Where(period.column+" < ?", period.end.AddDate(0, 0, 1).Unix()) + } + } for _, field := range []struct { column string value *int @@ -105,14 +128,21 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter q = q.Where("e.qualified_at IS NULL") } } - if err := q.Count(&page.Total).Error; err != nil { + var counts struct{ Total, Normal, Locked, Terminated int64 } + if err := q.Session(&gorm.Session{}).Select("COUNT(*) AS total,COALESCE(SUM(CASE WHEN COALESCE(p.lock_status,0)=0 THEN 1 ELSE 0 END),0) AS normal,COALESCE(SUM(CASE WHEN COALESCE(p.lock_status,0) NOT IN (0,-2) THEN 1 ELSE 0 END),0) AS locked,COALESCE(SUM(CASE WHEN p.lock_status=-2 THEN 1 ELSE 0 END),0) AS terminated").Scan(&counts).Error; err != nil { return nil, business.ErrStorage } + page.Total = counts.Total + page.StatusCounts = &business.ChannelStatusCounts{Normal: counts.Normal, Locked: counts.Locked, Terminated: counts.Terminated} var pos []channelPO o := business.NewListOptions(options...) - keysQuery := q.Select("p.id,p.source_db").Order("p.create_time DESC,p.id DESC,p.source_db ASC").Offset(o.Offset).Limit(o.Limit) + order := "p.create_time DESC,p.id DESC,p.source_db ASC" + if filter.SortField == "createdAt" && filter.Ascending { + order = "p.create_time ASC,p.id ASC,p.source_db ASC" + } + keysQuery := q.Select("p.id,p.source_db").Order(order).Offset(o.Offset).Limit(o.Limit) pageQuery := joinEffectiveness(db.Table("tab_promote p").Joins("JOIN (?) page_ids ON page_ids.id=p.id AND page_ids.source_db=p.source_db", keysQuery)) - if err := pageQuery.Select("p.id,p.source_db,p.account,p.nickname,p.parent_name,p.real_name,p.promote_level,p.register_type,p.status,p.lock_status,p.parent_id,p.top_promote_id,p.busier_id,p.create_time,e.qualified_at").Order("p.create_time DESC,p.id DESC,p.source_db ASC").Scan(&pos).Error; err != nil { + if err := pageQuery.Select("p.*,e.qualified_at").Order(order).Scan(&pos).Error; err != nil { return nil, business.ErrStorage } if len(pos) == 0 { diff --git a/internal/data/business/channel_test.go b/internal/data/business/channel_test.go index 0e9e118..1b63485 100644 --- a/internal/data/business/channel_test.go +++ b/internal/data/business/channel_test.go @@ -3,7 +3,9 @@ package business import ( "context" "math/big" + "strconv" "testing" + "time" "gorm.io/gorm" "kra/internal/biz/business" @@ -40,6 +42,42 @@ func prepareChannels(t *testing.T) (*gorm.DB, business.ChannelRepo) { return db, NewChannelRepo(testData{db}) } +func TestChannelMetadataFiltersSortingAndFullStatusCounts(t *testing.T) { + db, repo := prepareChannels(t) + day := time.Date(2026, 11, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + for _, sql := range []string{"ALTER TABLE tab_promote ADD COLUMN mobile_phone TEXT", "ALTER TABLE tab_promote ADD COLUMN settlement_day_period INTEGER", "ALTER TABLE tab_promote ADD COLUMN last_login_time INTEGER"} { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + for i, status := range []int{0, -1, -2} { + if err := db.Exec("UPDATE tab_promote SET create_time=?,last_login_time=?,mobile_phone=?,settlement_day_period=7,lock_status=? WHERE source_db='lx' AND id=?", day.Unix()+int64(i+1)*100, day.Unix()+1000, "1381234001"+strconv.Itoa(i), status, 100+i).Error; err != nil { + t.Fatal(err) + } + } + scope := business.UserScope{SelfID: 9, OnlySelf: true} + seven := 7 + filter := business.ChannelFilter{Phone: "4001", SettlementDays: &seven, CreatedBegin: &day, CreatedEnd: &day, LoginBegin: &day, LoginEnd: &day, SortField: "createdAt", Ascending: true} + page, err := repo.List(context.Background(), scope, filter, business.ListLimit(1)) + if err != nil || page.Total != 3 || len(page.Rows) != 1 || page.Rows[0].ID != 100 || page.Rows[0].Phone != "13812340010" || page.Rows[0].SettlementDays == nil || *page.Rows[0].SettlementDays != 7 || page.Rows[0].LastLoginUnix == nil { + t.Fatalf("筛选、排序或元数据遗漏: %+v %v", page, err) + } + if page.StatusCounts.Normal != 1 || page.StatusCounts.Locked != 1 || page.StatusCounts.Terminated != 1 { + t.Fatalf("状态汇总必须覆盖全量三条而非当前页: %+v", page.StatusCounts) + } + filter.Ascending = false + page, err = repo.List(context.Background(), scope, filter, business.ListLimit(1)) + if err != nil || page.Rows[0].ID != 102 { + t.Fatalf("降序必须作用于所有页: %+v %v", page, err) + } + locked := -1 + filter.LockStatus = &locked + page, err = repo.List(context.Background(), scope, filter) + if err != nil || page.Total != 1 || page.StatusCounts.Normal != 0 || page.StatusCounts.Locked != 1 || page.StatusCounts.Terminated != 0 { + t.Fatalf("筛选变化应同步刷新汇总: %+v %v", page, err) + } +} + func channelTestScope() business.UserScope { return business.UserScope{SelfID: 8, IncludeSelf: true, DescendantPath: "0,7,8"} } diff --git a/internal/server/handler/analytics.go b/internal/server/handler/analytics.go index a603a0a..f1290da 100644 --- a/internal/server/handler/analytics.go +++ b/internal/server/handler/analytics.go @@ -16,6 +16,17 @@ func (h *Analytics) Channels(c *gin.Context) { Fail(c, "统计查询参数不正确") return } + if req.Export { + data, err := h.service.Export(c.Request.Context(), &req) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + c.Header("Content-Disposition", "attachment; filename=channel-statistics.xlsx") + c.Header("success", "true") + c.Data(200, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data) + return + } data, err := h.service.Channels(c.Request.Context(), &req) if err != nil { Fail(c, errors.FromError(err).Message) diff --git a/internal/service/business/analytics.go b/internal/service/business/analytics.go index ce3bb84..5d12a26 100644 --- a/internal/service/business/analytics.go +++ b/internal/service/business/analytics.go @@ -10,6 +10,7 @@ import ( type AnalyticsUsecase interface { List(context.Context, business.AnalyticsKind, business.AnalyticsFilter, int, int) (*business.AnalyticsPage, error) + ListAll(context.Context, business.AnalyticsKind, business.AnalyticsFilter) (*business.AnalyticsPage, error) } type AnalyticsService struct{ uc AnalyticsUsecase } @@ -24,22 +25,21 @@ func analyticsRequest(req *dto.AnalyticsQuery) (business.AnalyticsFilter, error) 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")} + f := business.AnalyticsFilter{Sources: sources, GameNames: splitQueryValues(req.GameNames), PromoteName: strings.TrimSpace(req.PromoteName), PromoteType: req.PromoteType, HasRegister: req.HasRegister, HasPay: req.HasPay, 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 == "") { - return f, business.ErrAnalyticsDate - } - if begin != "" && end != "" { - location := time.FixedZone("Asia/Shanghai", 8*3600) + location := time.FixedZone("Asia/Shanghai", 8*3600) + if begin != "" { b, err := time.ParseInLocation("2006-01-02", begin, location) if err != nil { return f, business.ErrAnalyticsDate } + f.StartDate = &b + } + if end != "" { e, err := time.ParseInLocation("2006-01-02", end, location) - if err != nil || e.Before(b) { + if err != nil || f.StartDate != nil && e.Before(*f.StartDate) { return f, business.ErrAnalyticsDate } - f.StartDate = &b f.EndDate = &e } return f, nil diff --git a/internal/service/business/analytics_excel.go b/internal/service/business/analytics_excel.go new file mode 100644 index 0000000..bc097bb --- /dev/null +++ b/internal/service/business/analytics_excel.go @@ -0,0 +1,46 @@ +package business + +import ( + "context" + "strconv" + + "kra/internal/biz/business" + "kra/internal/service/dto" +) + +func (s *AnalyticsService) Export(ctx context.Context, req *dto.AnalyticsQuery) ([]byte, error) { + f, err := analyticsRequest(req) + if err != nil { + return nil, err + } + page, err := s.uc.ListAll(ctx, business.ChannelAnalytics, f) + if err != nil { + return nil, err + } + if !page.Available { + return nil, business.ErrChannelReportsUnavailable + } + headers := []string{"所属平台", "渠道名称", "归属商务", "渠道类型", "新增注册", "活跃玩家", "充值额", "付费玩家", "新增付费", "付费率"} + rows := make([][]ledgerCell, 0, len(page.Rows)+1) + appendRow := func(row *business.AnalyticsRow, summary bool) { + platform, name, owner, kind := business.PlatformName(row.SourceDB), row.Name, row.BusinessName(), "—" + if row.PromoteType != nil { + if *row.PromoteType == 1 { + kind = "公会" + } else if *row.PromoteType == 0 { + kind = "个人" + } + } + if summary { + platform, name, owner, kind = "全部", "合计(有效端 "+strconv.FormatInt(page.EffectiveCount, 10)+")", "—", "—" + } + rows = append(rows, []ledgerCell{textCell(platform), textCell(name), textCell(owner), textCell(kind), numericCell(strconv.FormatInt(row.Counts.Register, 10)), numericCell(strconv.FormatInt(row.Counts.Active, 10)), numericCell(row.Pay), numericCell(strconv.FormatInt(row.Counts.Pay, 10)), numericCell(strconv.FormatInt(row.Counts.NewPay, 10)), textCell(row.PayRate + "%")}) + } + for _, row := range page.Rows { + appendRow(row, false) + } + if page.Summary != nil { + appendRow(page.Summary, true) + } + return ledgerWorkbook("渠道数据统计", headers, rows) +} diff --git a/internal/service/business/channel.go b/internal/service/business/channel.go index b8d4467..38b24ae 100644 --- a/internal/service/business/channel.go +++ b/internal/service/business/channel.go @@ -5,6 +5,7 @@ import ( "math" "strconv" "strings" + "time" "unicode/utf16" "kra/internal/biz/business" @@ -42,13 +43,34 @@ func (s *ChannelService) List(ctx context.Context, req *dto.ChannelQuery) (*dto. return nil, business.ErrChannelInput } filter := business.ChannelFilter{Sources: sources, BusierUserID: req.BusierUserID, BusierAccount: strings.TrimSpace(req.BusierAccount), Keyword: strings.TrimSpace(req.Keyword), Level: req.Level, RegisterType: req.RegisterType, Status: req.Status, LockStatus: req.LockStatus, Effective: req.Effective} + filter.Phone, filter.SettlementDays = strings.TrimSpace(req.Phone), req.SettlementDays + filter.SortField, filter.Ascending = req.SortField, req.SortOrder == "asc" + if filter.SortField != "" && filter.SortField != "createdAt" || filter.SettlementDays != nil && *filter.SettlementDays < 1 { + return nil, business.ErrChannelInput + } + for _, pair := range []struct { + begin, end string + outBegin, outEnd **time.Time + }{ + {req.CreatedBegin, req.CreatedEnd, &filter.CreatedBegin, &filter.CreatedEnd}, + {req.LoginBegin, req.LoginEnd, &filter.LoginBegin, &filter.LoginEnd}, + } { + parsed, err := analyticsRequest(&dto.AnalyticsQuery{BeginTime: pair.begin, EndTime: pair.end}) + if err != nil { + return nil, business.ErrChannelInput + } + *pair.outBegin, *pair.outEnd = parsed.StartDate, parsed.EndDate + } result, err := s.uc.List(ctx, filter, business.ListOffset((page-1)*size), business.ListLimit(size)) if err != nil { return nil, err } out := &dto.ChannelPage{Rows: []*dto.ChannelResponse{}, Total: result.Total, Available: result.Available} + if result.StatusCounts != nil { + out.StatusCounts = &dto.ChannelStatusCounts{Normal: result.StatusCounts.Normal, Locked: result.StatusCounts.Locked, Terminated: result.StatusCounts.Terminated} + } for _, c := range result.Rows { - out.Rows = append(out.Rows, &dto.ChannelResponse{ID: c.ID, SourceDB: c.SourceDB, PlatformName: business.PlatformName(c.SourceDB), Account: c.Account, Nickname: c.Nickname, Level: c.Level, ParentID: c.ParentID, ParentName: c.ParentName, RealName: c.RealName, RegisterType: c.RegisterType, BusierID: c.BusierID, BusierUserID: c.BusierUserID, BusierDisplayName: c.BusinessDisplayName(), Status: c.Status, LockStatus: c.LockStatus, GameCount: c.GameCount, PlayerCount: c.PlayerCount, TotalPayAmount: c.TotalPayAmount, Effective: c.QualifiedAt != nil, QualifiedAt: c.QualifiedAt, CreatedAt: c.CreatedUnix}) + out.Rows = append(out.Rows, &dto.ChannelResponse{ID: c.ID, SourceDB: c.SourceDB, PlatformName: business.PlatformName(c.SourceDB), Account: c.Account, Nickname: c.Nickname, Level: c.Level, ParentID: c.ParentID, ParentName: c.ParentName, RealName: c.RealName, RegisterType: c.RegisterType, BusierID: c.BusierID, BusierUserID: c.BusierUserID, BusierDisplayName: c.BusinessDisplayName(), Status: c.Status, LockStatus: c.LockStatus, GameCount: c.GameCount, PlayerCount: c.PlayerCount, TotalPayAmount: c.TotalPayAmount, Effective: c.QualifiedAt != nil, QualifiedAt: c.QualifiedAt, CreatedAt: c.CreatedUnix, LastLoginAt: c.LastLoginUnix, Phone: c.Phone, SettlementDays: c.SettlementDays}) } return out, nil } diff --git a/internal/service/business/date_range_test.go b/internal/service/business/date_range_test.go index e550709..e996481 100644 --- a/internal/service/business/date_range_test.go +++ b/internal/service/business/date_range_test.go @@ -8,7 +8,7 @@ import ( func TestRejectIncompleteAndReversedDateRanges(t *testing.T) { for _, dates := range [][2]string{{"2026-11-01", ""}, {"", "2026-11-01"}, {"2026-11-03", "2026-11-01"}, {"bad", "2026-11-01"}} { - if _, err := analyticsRequest(&dto.AnalyticsQuery{BeginTime: dates[0], EndTime: dates[1]}); err == nil { + if _, err := analyticsRequest(&dto.AnalyticsQuery{BeginTime: dates[0], EndTime: dates[1]}); dates[0] != "" && dates[1] != "" && err == nil { t.Errorf("analytics accepted %v", dates) } for _, spend := range []bool{false, true} { @@ -31,3 +31,18 @@ func TestRejectIncompleteAndReversedDateRanges(t *testing.T) { t.Fatal("account query silently ignored incomplete date range") } } + +func TestAnalyticsOpenEndedDatesAndMetricFilters(t *testing.T) { + no, yes := false, true + for _, dates := range [][2]string{{"", ""}, {"2026-11-01", ""}, {"", "2026-11-03"}} { + filter, err := analyticsRequest(&dto.AnalyticsQuery{BeginTime: dates[0], EndTime: dates[1], HasRegister: &no, HasPay: &yes}) + if err != nil || (filter.StartDate == nil) != (dates[0] == "") || (filter.EndDate == nil) != (dates[1] == "") || *filter.HasRegister || !*filter.HasPay { + t.Fatalf("独立起止日期及是否有数据筛选必须原样传递: %+v %v", filter, err) + } + } + for _, dates := range [][2]string{{"bad", ""}, {"", "bad"}, {"2026-11-03", "2026-11-01"}} { + if _, err := analyticsRequest(&dto.AnalyticsQuery{BeginTime: dates[0], EndTime: dates[1]}); err == nil { + t.Fatalf("invalid dates accepted: %v", dates) + } + } +} diff --git a/internal/service/dto/analytics.go b/internal/service/dto/analytics.go index 25cfbb9..a172645 100644 --- a/internal/service/dto/analytics.go +++ b/internal/service/dto/analytics.go @@ -1,11 +1,14 @@ package dto type AnalyticsQuery struct { + Export bool `form:"export"` Page int `form:"pageNum"` PageSize int `form:"pageSize"` Sources []string `form:"sourceDb"` GameNames []string `form:"gameName"` PromoteType *int `form:"promoteType"` + HasRegister *bool `form:"hasRegister"` + HasPay *bool `form:"hasPay"` PromoteName string `form:"promoteName"` BeginTime string `form:"beginTime"` EndTime string `form:"endTime"` diff --git a/internal/service/dto/channel.go b/internal/service/dto/channel.go index 93ffec7..64ee73d 100644 --- a/internal/service/dto/channel.go +++ b/internal/service/dto/channel.go @@ -3,22 +3,33 @@ package dto import "encoding/json" type ChannelQuery struct { - Effective *bool `form:"effective"` - Page int `form:"pageNum"` - PageSize int `form:"pageSize"` - Sources []string `form:"sourceDb"` - BusierUserID *uint `form:"busierUserId"` - BusierAccount string `form:"busierAccount"` - Keyword string `form:"keyword"` - Level *int `form:"promoteLevel"` - RegisterType *int `form:"registerType"` - Status *int `form:"status"` - LockStatus *int `form:"lockStatus"` + Effective *bool `form:"effective"` + Page int `form:"pageNum"` + PageSize int `form:"pageSize"` + Sources []string `form:"sourceDb"` + BusierUserID *uint `form:"busierUserId"` + BusierAccount string `form:"busierAccount"` + Keyword string `form:"keyword"` + Level *int `form:"promoteLevel"` + RegisterType *int `form:"registerType"` + Status *int `form:"status"` + LockStatus *int `form:"lockStatus"` + Phone string `form:"phone"` + SettlementDays *int `form:"settlementDays"` + CreatedBegin string `form:"createdBegin"` + CreatedEnd string `form:"createdEnd"` + LoginBegin string `form:"loginBegin"` + LoginEnd string `form:"loginEnd"` + SortField string `form:"sortField"` + SortOrder string `form:"sortOrder"` } type ChannelResponse struct { Effective bool `json:"effective"` QualifiedAt *int64 `json:"qualifiedAt"` CreatedAt *int64 `json:"createdAt"` + LastLoginAt *int64 `json:"lastLoginAt"` + Phone string `json:"phone"` + SettlementDays *int `json:"settlementDays"` ID uint64 `json:"id"` SourceDB string `json:"sourceDb"` PlatformName string `json:"platformName"` @@ -39,9 +50,15 @@ type ChannelResponse struct { TotalPayAmount *string `json:"totalPayAmount"` } type ChannelPage struct { - Rows []*ChannelResponse `json:"rows"` - Total int64 `json:"total"` - Available bool `json:"available"` + Rows []*ChannelResponse `json:"rows"` + Total int64 `json:"total"` + Available bool `json:"available"` + StatusCounts *ChannelStatusCounts `json:"statusCounts"` +} +type ChannelStatusCounts struct { + Normal int64 `json:"normal"` + Locked int64 `json:"locked"` + Terminated int64 `json:"terminated"` } type ChannelBindOption struct { SourceDB string `json:"sourceDb"` diff --git a/web/src/api/analytics.js b/web/src/api/analytics.js index ccd0a37..14a7a31 100644 --- a/web/src/api/analytics.js +++ b/web/src/api/analytics.js @@ -1,2 +1,3 @@ import service from '@/utils/request' export const listAnalytics = (kind, params) => service({ url: `/sw/${kind === 'channel' ? 'channelData' : 'gameData'}/list`, method: 'get', params }) +export const exportChannelAnalytics = params => service({ url: '/sw/channelData/list', method: 'get', params: { ...params, export: true }, responseType: 'blob' }) diff --git a/web/src/view/sw/ChannelList.vue b/web/src/view/sw/ChannelList.vue index fc23099..472dcf1 100644 --- a/web/src/view/sw/ChannelList.vue +++ b/web/src/view/sw/ChannelList.vue @@ -1,21 +1,27 @@