fix: 补齐渠道全量合计筛选导出及有效端目标遗漏

This commit is contained in:
Yvan
2026-11-05 10:57:06 +08:00
parent 297a892518
commit 1aa15d3dc8
22 changed files with 545 additions and 88 deletions
+32 -6
View File
@@ -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
+24
View File
@@ -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"
+14 -3
View File
@@ -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
+68 -5
View File
@@ -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<?", analyticsPairs(keys[start:min(start+300, len(keys))]), begin.Unix(), end.AddDate(0, 0, 1).Unix()).Count(&count).Error; err != nil {
q := analyticsPromotes(db, scope, f, "p").Joins("JOIN sys_business_channel_effectiveness e ON e.source_db=p.source_db AND e.promote_id=p.id").Joins("JOIN sys_business_effectiveness_state s ON s.id=1 AND s.rule_key=e.rule_key").Where("(p.source_db,"+root+") IN ? AND e.qualified_at IS NOT NULL", analyticsPairs(keys[start:min(start+300, len(keys))]))
if f.Begin != "" {
begin, _ := time.ParseInLocation("2006-01-02", f.Begin, location)
q = q.Where("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
+64
View File
@@ -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"
+35 -5
View File
@@ -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 {
+38
View File
@@ -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"}
}
+11
View File
@@ -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)
+8 -8
View File
@@ -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
@@ -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)
}
+23 -1
View File
@@ -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
}
+16 -1
View File
@@ -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)
}
}
}
+3
View File
@@ -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"`
+31 -14
View File
@@ -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"`