fix: align business reports and record task migration progress

This commit is contained in:
Yvan
2026-11-06 00:47:06 +08:00
parent 1bc2082718
commit b1f14bd525
32 changed files with 501 additions and 53 deletions
+6 -2
View File
@@ -53,11 +53,15 @@ func AlertLossAt(now time.Time) AlertLossWindow {
return AlertLossWindow{LoginBefore: now.AddDate(0, 0, -15).Unix(), PayBegin: now.AddDate(0, 0, -30).Unix(), PayEnd: now.Unix(), MinPay: "500"}
}
func (w AlertLossWindow) IsLost(lastLogin, created int64, pay string) (bool, error) {
func (w AlertLossWindow) LoginExpired(lastLogin, created int64) bool {
if lastLogin <= 0 {
lastLogin = created
}
if lastLogin > 0 && lastLogin < w.LoginBefore {
return lastLogin > 0 && lastLogin < w.LoginBefore
}
func (w AlertLossWindow) IsLost(lastLogin, created int64, pay string) (bool, error) {
if w.LoginExpired(lastLogin, created) {
return true, nil
}
amount, err := portraitNumber(pay)
+5
View File
@@ -3,6 +3,7 @@ package business
import (
"context"
"fmt"
"kra/pkg/task"
"log/slog"
"math/big"
"sort"
@@ -152,6 +153,10 @@ func (uc *AlertTriggerUsecase) Detect(ctx context.Context, now time.Time) (*Aler
if input == nil {
return result, ErrStorage
}
task.Logf(ctx, "预警检测:规则=%d;候选一级渠道=%d;商务账号=%d", len(input.Rules), len(input.Channels), len(input.Accounts))
defer func() {
task.Logf(ctx, "预警检测结果:已检测规则=%d;跳过规则=%d;写入预警=%d", result.Rules, result.Skipped, result.Written)
}()
zone := time.FixedZone("Asia/Shanghai", 8*3600)
now = now.In(zone)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, zone)
+28 -3
View File
@@ -27,6 +27,7 @@ type AnalyticsKey struct {
ID int64
}
type AnalyticsFilter struct {
HasRegister, HasPay *bool
Sources, GameNames []string
PromoteName string
PromoteType *int
@@ -47,6 +48,22 @@ type AnalyticsRow struct {
Pay, PayRate, ARPU, ARPPU string
}
func (f AnalyticsFilter) MatchesMetrics(row *AnalyticsRow) (bool, error) {
if f.HasRegister != nil && (row.Counts.Register > 0) != *f.HasRegister {
return false, nil
}
if f.HasPay != nil {
amount, err := portraitNumber(row.Pay)
if err != nil {
return false, err
}
if (amount.Sign() > 0) != *f.HasPay {
return false, nil
}
}
return true, nil
}
func (r *AnalyticsRow) BusinessName() string {
if value := strings.TrimSpace(r.BindingAccount); value != "" {
return value
@@ -135,12 +152,20 @@ func (uc *AnalyticsUsecase) list(ctx context.Context, kind AnalyticsKind, filter
if kind == GameAnalytics {
filter.PromoteName = ""
filter.PromoteType = nil
filter.HasRegister, filter.HasPay = nil, nil
}
filter.StartDate, filter.EndDate = reportDateDefaults(filter.StartDate, filter.EndDate, 30, uc.now())
if filter.EndDate.Before(*filter.StartDate) {
if kind == GameAnalytics {
filter.StartDate, filter.EndDate = reportDateDefaults(filter.StartDate, filter.EndDate, 1, uc.now())
} else if filter.EndDate == nil {
_, filter.EndDate = reportDateDefaults(nil, nil, 1, uc.now())
}
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}
+4 -4
View File
@@ -63,8 +63,8 @@ func TestGameAnalyticsSummaryUsesAllRowsAndCorrectDenominators(t *testing.T) {
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-10-06" || page.End != "2026-11-04" {
t.Fatal("后台缺省统计日期应为含今天的近30天")
if page.Begin != "2026-11-04" || page.End != "2026-11-04" {
t.Fatal("游戏数据缺省统计日期应为当天")
}
}
func TestChannelAnalyticsSummaryDependsOnViewer(t *testing.T) {
@@ -90,8 +90,8 @@ func TestChannelAnalyticsAllPagesSummaryAndDefaultDateRange(t *testing.T) {
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 != "2026-10-06" || page.End != "2026-11-04" || repo.filter.StartDate == nil {
t.Fatalf("渠道统计缺省应为含今天的近30天: %+v", repo.filter)
if page.Begin != "" || page.End != "2026-11-04" || repo.filter.StartDate != nil {
t.Fatalf("渠道统计开始留空不应截断历史数据: %+v", repo.filter)
}
}
for _, sample := range []struct {
+15 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"fmt"
"kra/pkg/task"
"math"
"math/big"
"strconv"
@@ -98,6 +99,7 @@ func (uc *EffectivenessUsecase) rebuild(ctx context.Context, rule OtherSettings,
if err != nil {
return nil, err
}
task.Logf(ctx, "有效端计算:全量=%t;注册后 %d 天内满 %d 元;待计算渠道=%d", full, rule.Days, rule.Amount, len(candidates))
type accumulator struct {
candidate *EffectivenessCandidate
sum *big.Rat
@@ -120,7 +122,9 @@ func (uc *EffectivenessUsecase) rebuild(ctx context.Context, rule OtherSettings,
}
values[c.ChannelKey] = a
}
charges := 0
err = uc.repo.Charges(ctx, candidates, duration, now, full, func(charge EffectivenessCharge) error {
charges++
a := values[charge.ChannelKey]
if a == nil {
return nil
@@ -144,5 +148,15 @@ func (uc *EffectivenessUsecase) rebuild(ctx context.Context, rule OtherSettings,
a := values[c.ChannelKey]
results = append(results, EffectivenessResult{ChannelKey: c.ChannelKey, QualifiedAt: a.at, WindowPay: portraitDecimal(a.sum, 2), Complete: a.complete})
}
return uc.repo.Publish(ctx, state, rule, key, results, save)
updated, err := uc.repo.Publish(ctx, state, rule, key, results, save)
if err == nil {
qualified := 0
for _, result := range results {
if result.QualifiedAt != nil {
qualified++
}
}
task.Logf(ctx, "有效端结果已提交;扫描流水=%d;更新渠道=%d;本批达标渠道=%d", charges, len(results), qualified)
}
return updated, err
}
+12 -7
View File
@@ -172,7 +172,7 @@ func TestPerformanceRateSortKeepsUndefinedRatesLast(t *testing.T) {
}
}
func TestPerformanceMatrixTotalsKeepVisibleScopeWhenRowsAreFiltered(t *testing.T) {
func TestPerformanceMatrixTotalsDeduplicateDisplayedMembers(t *testing.T) {
_, uc, _, teams, _ := managementFixture()
teams.catalog.Groups[0].MemberIDs = []uint{8, 9, 10}
ctx := accountContext(7, 901)
@@ -181,22 +181,27 @@ func TestPerformanceMatrixTotalsKeepVisibleScopeWhenRowsAreFiltered(t *testing.T
if err != nil {
t.Fatal(err)
}
if len(page.Rows) != 1 || page.MatrixTotal != page.Total || page.MatrixTotal.Open != 2 || page.Groups[0].UniqueTotal.Open != 1 {
t.Fatalf("matrix total must retain the full visible scope, with a filtered group subtotal: %+v", page)
if len(page.Rows) != 1 || page.MatrixTotal.Open != 1 || page.Groups[0].UniqueTotal.Open != 1 {
t.Fatalf("matrix and group subtotal must reflect the displayed member scope: %+v", page)
}
if page.Total.Open != 2 || page.Groups[0].Total.Open != 3 {
t.Fatal("scope cards and team contributions must retain their own scope")
}
f.Name, f.GroupID = "", func() *uint64 { id := uint64(999); return &id }()
page, err = uc.Query(ctx, f)
if err != nil || len(page.Rows) != 0 || page.MatrixTotal != page.Total || page.Total.Open != 2 {
if err != nil || len(page.Rows) != 0 || page.MatrixTotal.Open != 0 || page.MatrixTotal.Effective != 0 || page.MatrixTotal.Pay != "0.00" || page.Total.Open != 2 {
t.Fatalf("empty displayed rows must not erase visible-scope totals: %+v %v", page, err)
}
for period, total := range page.Trend {
if page.MatrixTrend[period] != total {
t.Fatalf("period total changed with filtering: %s", period)
for period, total := range page.MatrixTrend {
if total.Open != 0 || total.Effective != 0 || total.Pay != "0.00" {
t.Fatalf("empty matrix retained period values: %s", period)
}
}
f.GroupID = nil
page, err = uc.Query(ctx, f)
if err != nil || page.MatrixTotal != page.Total || page.MatrixTotal.Open != 2 {
t.Fatalf("overlapping ancestors must contribute only once: %+v %v", page, err)
}
}
func TestBusinessManagementIncludesCurrentGoalWithoutCurrentPerformance(t *testing.T) {
+11 -2
View File
@@ -173,6 +173,7 @@ func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ow
return out, nil
}
all := newPerformanceBuckets(periods)
matrix := newPerformanceBuckets(periods)
needle := strings.ToLower(strings.TrimSpace(f.Name))
visible := func(m *BusinessMember) bool {
return (f.Level == nil || m.Level == *f.Level) && (needle == "" || strings.Contains(strings.ToLower(memberName(m)+" "+m.Username+" "+m.RealName), needle))
@@ -259,6 +260,14 @@ func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ow
return
}
all.add(key, metric, pay)
// The matrix is the union of the displayed members' descendant scopes;
// overlapping parent/child rows must not count the same channel twice.
for _, id := range a.members {
if member := byID[id]; member != nil && matrixVisible(member) {
matrix.add(key, metric, pay)
break
}
}
for _, id := range a.members {
memberBuckets[id].add(key, metric, pay)
}
@@ -361,9 +370,9 @@ func (uc *PerformanceUsecase) query(ctx context.Context, f PerformanceFilter, ow
}
}
out.Total = all.total.metrics()
out.MatrixTotal = out.Total
out.MatrixTotal = matrix.total.metrics()
out.MatrixRate = performanceRate(out.MatrixTotal)
out.MatrixTrend = all.values()
out.MatrixTrend = matrix.values()
out.Prior = prior.metrics()
out.Rate = performanceRate(out.Total)
out.Trend = all.values()
+24 -1
View File
@@ -2,6 +2,7 @@ package business
import (
"context"
"kra/pkg/task"
"math/big"
"sort"
"strconv"
@@ -103,13 +104,35 @@ func (uc *ReportBuildUsecase) Run(ctx context.Context, kind ReportBuildKind, per
if finish.Before(begin) {
return ErrAnalyticsDateOrder
}
task.Logf(ctx, "报表类型=%s;日期区间=%s 至 %s", kind, begin.Format("2006-01-02"), finish.Format("2006-01-02"))
completed, totalRows, totalKeys := 0, 0, 0
defer func() {
task.Logf(ctx, "已提交日期=%d;汇总行=%d;身份键=%d", completed, totalRows, totalKeys)
}()
for day := begin; !day.After(finish); day = day.AddDate(0, 0, 1) {
if err := ctx.Err(); err != nil {
return err
}
if err := uc.repo.Rebuild(ctx, kind, day, func(input *ReportInput) ([]*ReportBuiltRow, error) { return compileReport(kind, input) }); err != nil {
started := time.Now()
registers, actives, payments, rowCount, keyCount := 0, 0, 0, 0, 0
if err := uc.repo.Rebuild(ctx, kind, day, func(input *ReportInput) ([]*ReportBuiltRow, error) {
registers, actives, payments = len(input.Registers), len(input.Actives), len(input.Payments)
rows, err := compileReport(kind, input)
rowCount = len(rows)
for _, row := range rows {
for _, keys := range row.Keys {
keyCount += len(keys)
}
}
return rows, err
}); err != nil {
task.Logf(ctx, "日期=%s 未提交:%v", day.Format("2006-01-02"), err)
return err
}
completed++
totalRows += rowCount
totalKeys += keyCount
task.Logf(ctx, "日期=%s 已提交;原始注册=%d,活跃=%d,充值=%d;汇总行=%d,身份键=%d;耗时=%d ms", day.Format("2006-01-02"), registers, actives, payments, rowCount, keyCount, time.Since(started).Milliseconds())
if day.Before(finish) {
if err := uc.pause(ctx); err != nil {
return err
@@ -2,6 +2,9 @@ package business
import (
"context"
"errors"
"kra/pkg/task"
"strings"
"testing"
"time"
)
@@ -77,6 +80,28 @@ func TestReportBuildIdentityRulesAndAnonymousLogins(t *testing.T) {
type reportBuildFake struct{ days []string }
type failingReportCommit struct{}
func (failingReportCommit) Rebuild(_ context.Context, _ ReportBuildKind, _ time.Time, compile func(*ReportInput) ([]*ReportBuiltRow, error)) error {
_, err := compile(&ReportInput{Registers: []ReportEvent{{SourceDB: "lx", UserID: reportPtr(1), PromoteID: reportPtr(1), GameID: reportPtr(1)}}})
if err != nil {
return err
}
return errors.New("commit failed")
}
func TestReportBuildDetailsDoNotClaimRolledBackRows(t *testing.T) {
ctx, output := task.WithOutput(context.Background())
uc := NewReportBuildUsecase(failingReportCommit{})
if err := uc.Run(ctx, DailyReport, "backfill", "2026-11-03", "2026-11-03"); err == nil {
t.Fatal("commit error was lost")
}
text := output.String()
if !strings.Contains(text, "2026-11-03 未提交") || !strings.Contains(text, "已提交日期=0;汇总行=0;身份键=0") {
t.Fatalf("rollback was reported as success: %s", text)
}
}
func (r *reportBuildFake) Rebuild(_ context.Context, _ ReportBuildKind, day time.Time, _ func(*ReportInput) ([]*ReportBuiltRow, error)) error {
r.days = append(r.days, day.Format("2006-01-02"))
return nil
+8 -5
View File
@@ -5,7 +5,7 @@ import (
"time"
)
func TestAnalyticsDefaultDatesBoundListAndExportQueries(t *testing.T) {
func TestAnalyticsDateRulesAgreeForListAndExport(t *testing.T) {
date := func(value string) *time.Time {
parsed, err := time.ParseInLocation("2006-01-02", value, portraitLocation)
if err != nil {
@@ -18,13 +18,16 @@ func TestAnalyticsDefaultDatesBoundListAndExportQueries(t *testing.T) {
name, start, end, wantStart, wantEnd string
now time.Time
}{
{"empty", "", "", "2026-10-06", "2026-11-04", time.Date(2026, 11, 4, 9, 0, 0, 0, portraitLocation)},
{"China day across UTC midnight", "", "", "2025-12-03", "2026-01-01", time.Date(2025, 12, 31, 17, 0, 0, 0, time.UTC)},
{"only end across leap day", "", "2024-03-01", "2024-02-01", "2024-03-01", time.Now()},
{"empty", "", "", "", "2026-11-04", time.Date(2026, 11, 4, 9, 0, 0, 0, portraitLocation)},
{"China day across UTC midnight", "", "", "", "2026-01-01", time.Date(2025, 12, 31, 17, 0, 0, 0, time.UTC)},
{"only end across leap day", "", "2024-03-01", "", "2024-03-01", time.Now()},
{"only start", "2026-01-01", "", "2026-01-01", "2026-11-04", time.Date(2026, 11, 4, 9, 0, 0, 0, portraitLocation)},
{"explicit historical year", "2023-01-01", "2023-12-31", "2023-01-01", "2023-12-31", time.Now()},
} {
t.Run(string(kind)+"/"+sample.name, func(t *testing.T) {
if kind == GameAnalytics && sample.wantStart == "" {
sample.wantStart = sample.wantEnd
}
uc, repo := analyticsFixture()
uc.now = func() time.Time { return sample.now }
filter := AnalyticsFilter{}
@@ -45,7 +48,7 @@ func TestAnalyticsDefaultDatesBoundListAndExportQueries(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if page.Begin != sample.wantStart || page.End != sample.wantEnd || repo.filter.Begin != page.Begin || repo.filter.End != page.End || repo.filter.StartDate == nil || repo.filter.EndDate == nil {
if page.Begin != sample.wantStart || page.End != sample.wantEnd || repo.filter.Begin != page.Begin || repo.filter.End != page.End || (repo.filter.StartDate == nil) != (sample.wantStart == "") || repo.filter.EndDate == nil {
t.Fatalf("export=%v expected %s..%s, got %+v", export, sample.wantStart, sample.wantEnd, repo.filter)
}
}