feat: 迁移日汇总任务并修复商务菜单图标

This commit is contained in:
Yvan
2026-11-04 01:53:08 +08:00
parent ac4e13e610
commit 5a96be6a79
27 changed files with 1446 additions and 24 deletions
+1 -1
View File
@@ -11,4 +11,4 @@ import (
)
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase)
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase)
+284
View File
@@ -0,0 +1,284 @@
package business
import (
"context"
"math/big"
"sort"
"strconv"
"strings"
"time"
"github.com/go-kratos/kratos/v3/errors"
)
var ErrReportBuildUnavailable = errors.BadRequest("SW_REPORT_BUILD_UNAVAILABLE", "原始数据或汇总表尚未接入,不能重建报表")
type ReportBuildKind string
const (
DailyReport ReportBuildKind = "day"
ServerReport ReportBuildKind = "server"
)
type ReportEvent struct {
SourceDB string
UserID, PromoteID, GameID *int64
ServerNum string
Amount string
}
type ReportUserKey struct {
SourceDB string
ID int64
}
type ReportUser struct {
ReportUserKey
ParentID, PromoteID *int64
IDCard string
}
type ReportInput struct {
Registers, Actives, Payments []ReportEvent
Users map[ReportUserKey]ReportUser
Games map[AnalyticsKey]string
}
type ReportGroup struct {
SourceDB string
PromoteID, GameID int64
ServerNum string
}
type ReportBuiltRow struct {
ReportGroup
Mode int
GameName, Pay string
Counts PortraitCounts
Keys map[int][]string
}
type ReportBuildRepo interface {
Rebuild(context.Context, ReportBuildKind, time.Time, func(*ReportInput) ([]*ReportBuiltRow, error)) error
}
type ReportBuildUsecase struct {
repo ReportBuildRepo
now func() time.Time
pause func(context.Context) error
}
func NewReportBuildUsecase(repo ReportBuildRepo) *ReportBuildUsecase {
return &ReportBuildUsecase{repo: repo, now: time.Now, pause: func(ctx context.Context) error {
timer := time.NewTimer(time.Second)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}}
}
func (uc *ReportBuildUsecase) Run(ctx context.Context, kind ReportBuildKind, period, start, end string) error {
if kind != DailyReport && kind != ServerReport {
return ErrAnalyticsInput
}
w := workbenchWindow(uc.now())
begin, finish := w.Yesterday, w.Yesterday
switch period {
case "today":
begin, finish = w.Today, w.Today
case "yesterday":
case "backfill":
var err error
if strings.TrimSpace(start) != "" {
begin, err = time.ParseInLocation("2006-01-02", strings.TrimSpace(start), portraitLocation)
if err != nil {
return ErrAnalyticsDate
}
}
if strings.TrimSpace(end) != "" {
finish, err = time.ParseInLocation("2006-01-02", strings.TrimSpace(end), portraitLocation)
if err != nil {
return ErrAnalyticsDate
}
}
default:
return ErrAnalyticsInput
}
if finish.Before(begin) {
return ErrAnalyticsDateOrder
}
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 {
return err
}
if day.Before(finish) {
if err := uc.pause(ctx); err != nil {
return err
}
}
}
return nil
}
type reportAggregate struct {
users map[int]map[int64]bool
pay *big.Rat
}
func compileReport(kind ReportBuildKind, input *ReportInput) ([]*ReportBuiltRow, error) {
groups := map[ReportGroup]*reportAggregate{}
number := func(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
group := func(event ReportEvent) ReportGroup {
return ReportGroup{SourceDB: event.SourceDB, PromoteID: number(event.PromoteID), GameID: number(event.GameID), ServerNum: func() string {
if kind == ServerReport {
return strings.TrimSpace(event.ServerNum)
}
return ""
}()}
}
get := func(key ReportGroup) *reportAggregate {
if groups[key] == nil {
groups[key] = &reportAggregate{pay: new(big.Rat), users: map[int]map[int64]bool{1: {}, 2: {}, 3: {}, 4: {}}}
}
return groups[key]
}
// 无平台登录仅在全库唯一主账号匹配时归属,提前建立索引避免逐条扫描账号。
unique := map[int64]*ReportUser{}
ambiguous := map[int64]bool{}
for _, user := range input.Users {
if user.ParentID == nil || *user.ParentID != 0 {
continue
}
if _, ok := unique[user.ID]; ok {
ambiguous[user.ID] = true
}
copy := user
unique[user.ID] = &copy
}
for _, event := range input.Registers {
if event.UserID == nil {
continue
}
if kind == ServerReport && (strings.TrimSpace(event.ServerNum) == "" || event.GameID == nil) {
continue
}
get(group(event)).users[1][*event.UserID] = true
}
for _, event := range input.Actives {
if event.UserID == nil {
continue
}
if kind == ServerReport {
if strings.TrimSpace(event.ServerNum) == "" || event.GameID == nil {
continue
}
} else {
var user *ReportUser
if strings.TrimSpace(event.SourceDB) == "" {
if ambiguous[*event.UserID] {
continue
}
user = unique[*event.UserID]
if user != nil {
event.SourceDB = user.SourceDB
}
} else {
if value, ok := input.Users[ReportUserKey{SourceDB: event.SourceDB, ID: *event.UserID}]; ok {
user = &value
}
}
if user == nil || user.ParentID == nil || *user.ParentID != 0 {
continue
}
if event.PromoteID == nil {
event.PromoteID = user.PromoteID
}
}
get(group(event)).users[2][*event.UserID] = true
}
for _, event := range input.Payments {
if kind == ServerReport && strings.TrimSpace(event.ServerNum) == "" {
continue
}
a := get(group(event))
if event.UserID != nil {
a.users[3][*event.UserID] = true
}
amount, err := portraitNumber(event.Amount)
if err != nil {
return nil, err
}
a.pay.Add(a.pay, amount)
}
keys := make([]ReportGroup, 0, len(groups))
for key := range groups {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
a, b := keys[i], keys[j]
if a.SourceDB != b.SourceDB {
return a.SourceDB < b.SourceDB
}
if a.PromoteID != b.PromoteID {
return a.PromoteID < b.PromoteID
}
if a.GameID != b.GameID {
return a.GameID < b.GameID
}
return a.ServerNum < b.ServerNum
})
out := []*ReportBuiltRow{}
for _, key := range keys {
a := groups[key]
if len(a.users[1])+len(a.users[2])+len(a.users[3]) == 0 && a.pay.Sign() == 0 {
continue
}
if kind == DailyReport {
for id := range a.users[3] {
if a.users[1][id] {
a.users[4][id] = true
}
}
}
for mode := 0; mode <= 1; mode++ {
row := &ReportBuiltRow{ReportGroup: key, Mode: mode, GameName: input.Games[AnalyticsKey{SourceDB: key.SourceDB, ID: key.GameID}], Pay: portraitDecimal(a.pay, 2), Keys: map[int][]string{}}
for metric, ids := range a.users {
if metric == 4 && kind == ServerReport {
continue
}
identities := map[string]bool{}
for id := range ids {
identity := strconv.FormatInt(id, 10)
if mode == 1 {
identity = normalizeIDCard(input.Users[ReportUserKey{SourceDB: key.SourceDB, ID: id}].IDCard)
}
if identity != "" {
identities[identity] = true
}
}
values := make([]string, 0, len(identities))
for value := range identities {
values = append(values, value)
}
sort.Strings(values)
row.Keys[metric] = values
switch metric {
case 1:
row.Counts.Register = int64(len(values))
case 2:
row.Counts.Active = int64(len(values))
case 3:
row.Counts.Pay = int64(len(values))
case 4:
row.Counts.NewPay = int64(len(values))
}
}
out = append(out, row)
}
}
return out, nil
}
+105
View File
@@ -0,0 +1,105 @@
package business
import (
"context"
"testing"
"time"
)
func reportPtr(n int64) *int64 { return &n }
func TestReportBuildIdentityRulesAndAnonymousLogins(t *testing.T) {
input := &ReportInput{Users: map[ReportUserKey]ReportUser{}, Games: map[AnalyticsKey]string{{SourceDB: "lx", ID: 1}: "游戏"}}
for _, u := range []ReportUser{{ReportUserKey: ReportUserKey{"lx", 1}, ParentID: reportPtr(0), PromoteID: reportPtr(10), IDCard: " aaX "}, {ReportUserKey: ReportUserKey{"hw", 1}, ParentID: reportPtr(0), PromoteID: reportPtr(20), IDCard: "b"}, {ReportUserKey: ReportUserKey{"lx", 2}, ParentID: reportPtr(0), PromoteID: reportPtr(10), IDCard: "aax"}, {ReportUserKey: ReportUserKey{"lx", 3}, ParentID: reportPtr(1), PromoteID: reportPtr(10), IDCard: "sub"}, {ReportUserKey: ReportUserKey{"lx", 4}, PromoteID: reportPtr(10)}, {ReportUserKey: ReportUserKey{"lx", 5}, ParentID: reportPtr(0), PromoteID: reportPtr(10)}} {
input.Users[u.ReportUserKey] = u
}
event := func(id int64) ReportEvent {
return ReportEvent{SourceDB: "lx", UserID: reportPtr(id), PromoteID: reportPtr(10), GameID: reportPtr(1)}
}
input.Registers = []ReportEvent{event(1), event(2), event(5)}
missing := event(2)
missing.SourceDB = ""
missing.PromoteID = nil
ambiguous := event(1)
ambiguous.SourceDB = ""
input.Actives = []ReportEvent{event(1), missing, ambiguous, event(3), event(4)}
for _, id := range []int64{1, 3, 99} {
p := event(id)
p.Amount = "10"
input.Payments = append(input.Payments, p)
}
noUser := event(1)
noUser.UserID = nil
noUser.Amount = "1"
input.Payments = append(input.Payments, noUser)
rows, err := compileReport(DailyReport, input)
if err != nil || len(rows) != 2 {
t.Fatalf("汇总分组错误: %+v %v", rows, err)
}
for _, row := range rows {
if row.Pay != "31.00" || row.GameName != "游戏" || row.Counts.NewPay != 1 {
t.Fatalf("金额、名称或新增付费错误: %+v", row)
}
if row.Mode == 0 && (row.Counts.Register != 3 || row.Counts.Active != 2 || row.Counts.Pay != 3) {
t.Fatalf("账号口径未保留主账号登录/原始付费账号: %+v", row)
}
if row.Mode == 1 && (row.Counts.Register != 1 || row.Counts.Active != 1 || row.Counts.Pay != 2) {
t.Fatalf("身份证口径、空证件或末尾 X 归一错误: %+v", row)
}
}
input.Registers = []ReportEvent{event(2)}
input.Payments = []ReportEvent{event(1)}
input.Payments[0].Amount = "10"
rows, err = compileReport(DailyReport, input)
if err != nil {
t.Fatal(err)
}
for _, row := range rows {
if row.Counts.NewPay != 0 {
t.Fatal("新增付费必须先取原始账号交集,不能把相同证件的其他付费账号算入")
}
}
server := event(3)
server.ServerNum = " 001 "
input.Registers = []ReportEvent{server}
input.Actives = []ReportEvent{server}
input.Payments = []ReportEvent{server}
input.Payments[0].Amount = "20"
rows, err = compileReport(ServerReport, input)
if err != nil || len(rows) != 2 {
t.Fatal("区服汇总失败")
}
for _, row := range rows {
if row.ServerNum != "001" || row.Counts.Register != 1 || row.Counts.Active != 1 || row.Counts.Pay != 1 || len(row.Keys[4]) != 0 {
t.Fatal("区服创角/活跃不能套用主账号限制,也不能生成新增付费指标")
}
}
}
type reportBuildFake struct{ days []string }
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
}
func TestReportBuildBackfillRangeAndCancellation(t *testing.T) {
r := &reportBuildFake{}
uc := NewReportBuildUsecase(r)
uc.now = func() time.Time { return time.Date(2026, 11, 4, 9, 0, 0, 0, portraitLocation) }
pauses := 0
uc.pause = func(context.Context) error { pauses++; return nil }
if err := uc.Run(context.Background(), DailyReport, "backfill", "2026-10-31", "2026-11-02"); err != nil || len(r.days) != 3 || pauses != 2 || r.days[2] != "2026-11-02" {
t.Fatal("回跑日期应闭区间且仅日间暂停")
}
if err := uc.Run(context.Background(), DailyReport, "backfill", "bad", ""); err != ErrAnalyticsDate {
t.Fatal("非法日期未拒绝")
}
if err := uc.Run(context.Background(), DailyReport, "backfill", "2026-11-05", "2026-11-04"); err != ErrAnalyticsDateOrder {
t.Fatal("倒置区间未拒绝")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
count := len(r.days)
if err := uc.Run(ctx, ServerReport, "today", "", ""); err != context.Canceled || len(r.days) != count {
t.Fatal("取消后仍重建报表")
}
}