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("取消后仍重建报表")
}
}
+39
View File
@@ -0,0 +1,39 @@
package business
import "gorm.io/gorm"
// 使用 Kra 全局注册的 Element Plus 组件名;不可直接沿用 RuoYi 的 SVG 文件名。
var businessMenuIcons = map[string]string{
"sw_BusMgmt": "Briefcase",
"sw_BusinessList": "UserFilled",
"sw_ChannelList": "Connection",
"sw_ChannelActivationList": "CircleCheck",
"sw_DataAnalysis": "DataAnalysis",
"sw_ChannelDataStatistics": "Histogram",
"sw_RechargeDetails": "Money",
"sw_RegistrationDetails": "User",
"sw_GameAnalytics": "TrendCharts",
"sw_ServerAnalysis": "Monitor",
"sw_RankingAnalysis": "Star",
"sw_AlertCenter": "Bell",
"sw_AlertList": "Warning",
"sw_AlertRules": "Setting",
}
func MenuIcon(name string) string { return businessMenuIcons[name] }
func migrateBusinessMenuIcons(db *gorm.DB) error {
if !db.Migrator().HasTable("sys_base_menus") {
return nil
}
// 仅替换旧迁移和早期模块默认值,保留管理员后来选取的其他图标。
legacy := []string{"", "category", "email", "chart", "company", "international", "color", "dict", "cascader", "education", "excel", "star", "process-definition", "documentation", "example", "data-analysis", "user", "money", "warn"}
return db.Transaction(func(tx *gorm.DB) error {
for name, icon := range businessMenuIcons {
if err := tx.Table("sys_base_menus").Where("name=? AND (icon IS NULL OR icon IN ?)", name, legacy).Update("icon", icon).Error; err != nil {
return err
}
}
return nil
})
}
+34
View File
@@ -0,0 +1,34 @@
package business
import "testing"
func TestBusinessMenuIconsRepairLegacyAndPreserveCustom(t *testing.T) {
db := openTestDB(t)
if err := db.Exec("CREATE TABLE sys_base_menus(id INTEGER PRIMARY KEY,name TEXT,icon TEXT)").Error; err != nil {
t.Fatal(err)
}
for _, sql := range []string{
"INSERT INTO sys_base_menus VALUES(1,'sw_BusMgmt','category'),(2,'sw_BusinessList','email'),(3,'sw_ChannelList','Connection'),(4,'sw_ChannelActivationList','CirclePlus'),(5,'sw_DataAnalysis',NULL),(6,'dashboard','category')",
} {
if err := db.Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
for attempt := 0; attempt < 2; attempt++ {
if err := migrateBusinessMenuIcons(db); err != nil {
t.Fatal(err)
}
}
var rows []struct {
ID int
Icon string
}
if err := db.Table("sys_base_menus").Order("id").Scan(&rows).Error; err != nil {
t.Fatal(err)
}
for i, want := range []string{"Briefcase", "UserFilled", "Connection", "CirclePlus", "DataAnalysis", "category"} {
if rows[i].Icon != want {
t.Fatalf("菜单%d图标错误或误改自定义值: %+v", i, rows)
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
)
func Migrations() []migration.Step {
return []migration.Step{{ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
return []migration.Step{{ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &resetCodePO{}, &resetTokenPO{})
}}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &bindingLockPO{})
+206
View File
@@ -0,0 +1,206 @@
package business
import (
"context"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"kra/internal/biz/business"
)
type reportBuildRepo struct{ data Provider }
func NewReportBuildRepo(data Provider) business.ReportBuildRepo { return &reportBuildRepo{data: data} }
type reportBuildLockPO struct {
Kind string `gorm:"size:16;primaryKey"`
StatDate string `gorm:"type:date;primaryKey"`
}
func (reportBuildLockPO) TableName() string { return "sys_business_report_build_locks" }
type reportEventPO struct {
SourceDB string `gorm:"column:source_db"`
UserID, PromoteID, GameID *int64
ServerID, PayAmount string
}
func toReportEvent(p reportEventPO) business.ReportEvent {
return business.ReportEvent{SourceDB: p.SourceDB, UserID: p.UserID, PromoteID: p.PromoteID, GameID: p.GameID, ServerNum: p.ServerID, Amount: p.PayAmount}
}
func (r *reportBuildRepo) Rebuild(ctx context.Context, kind business.ReportBuildKind, day time.Time, compile func(*business.ReportInput) ([]*business.ReportBuiltRow, error)) error {
if kind != business.DailyReport && kind != business.ServerReport {
return business.ErrAnalyticsInput
}
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
reportTable, keyTable := "tab_sw_report_day", "tab_sw_report_day_key"
rawTable := "tab_user_day_login"
if kind == business.ServerReport {
reportTable, keyTable = "tab_sw_report_server_day", "tab_sw_report_server_day_key"
rawTable = "tab_user_play_info"
}
for _, table := range []string{reportTable, keyTable, rawTable, "tab_user", "tab_spend", "tab_game"} {
available, err := businessTableAvailable(tx, table)
if err != nil {
return err
}
if !available {
return business.ErrReportBuildUnavailable
}
}
statDate := day.Format("2006-01-02")
lock := reportBuildLockPO{Kind: string(kind), StatDate: statDate}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&lock).Error; err != nil {
return business.ErrStorage
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("kind=? AND stat_date=?", lock.Kind, lock.StatDate).Take(&lock).Error; err != nil {
return business.ErrStorage
}
input, err := loadReportInput(tx, kind, day)
if err != nil {
return err
}
rows, err := compile(input)
if err != nil {
return err
}
if err := tx.Exec("DELETE FROM "+keyTable+" WHERE stat_date=?", statDate).Error; err != nil {
return business.ErrStorage
}
if err := tx.Exec("DELETE FROM "+reportTable+" WHERE stat_date=?", statDate).Error; err != nil {
return business.ErrStorage
}
reports := make([]map[string]any, 0, len(rows))
keys := []map[string]any{}
for _, row := range rows {
base := func() map[string]any {
out := map[string]any{"stat_date": statDate, "source_db": row.SourceDB, "promote_id": row.PromoteID, "game_id": row.GameID, "game_name": row.GameName, "dedup_mode": row.Mode}
if kind == business.ServerReport {
out["server_num"] = row.ServerNum
}
return out
}
report := base()
report["new_register_cnt"], report["active_cnt"], report["pay_cnt"], report["total_pay"] = row.Counts.Register, row.Counts.Active, row.Counts.Pay, row.Pay
if kind == business.DailyReport {
report["new_pay_cnt"] = row.Counts.NewPay
}
reports = append(reports, report)
for metric := 1; metric <= 4; metric++ {
for _, identity := range row.Keys[metric] {
key := base()
key["metric"], key["identity_key"] = metric, identity
keys = append(keys, key)
}
}
}
// 汇总和身份键始终同事务替换,失败或取消会恢复原日报。
for _, batch := range []struct {
table string
rows []map[string]any
}{{reportTable, reports}, {keyTable, keys}} {
if len(batch.rows) > 0 {
if err := tx.Table(batch.table).CreateInBatches(batch.rows, 100).Error; err != nil {
return business.ErrStorage
}
}
}
return nil
})
}
func loadReportInput(db *gorm.DB, kind business.ReportBuildKind, day time.Time) (*business.ReportInput, error) {
input := &business.ReportInput{Users: map[business.ReportUserKey]business.ReportUser{}, Games: map[business.AnalyticsKey]string{}}
begin, end := day.Unix(), day.AddDate(0, 0, 1).Unix()-1
userIDs, gameIDs := map[int64]bool{}, map[int64]bool{}
collect := func(rows []reportEventPO, target *[]business.ReportEvent) {
for _, po := range rows {
*target = append(*target, toReportEvent(po))
if po.UserID != nil {
userIDs[*po.UserID] = true
}
if po.GameID != nil {
gameIDs[*po.GameID] = true
} else {
gameIDs[0] = true
}
}
}
if kind == business.DailyReport {
cast := "CAST(register_time AS INTEGER)"
if db.Dialector.Name() == "mysql" {
cast = "CAST(register_time AS UNSIGNED)"
}
var registers, actives []reportEventPO
if err := db.Table("tab_user").Select("id AS user_id,source_db,promote_id,fgame_id AS game_id").Where("puid=0").Where(cast+" BETWEEN ? AND ?", begin, end).Scan(&registers).Error; err != nil {
return nil, business.ErrStorage
}
if err := db.Table("tab_user_day_login").Select("user_id,source_db,promote_id,game_id").Where("login_time BETWEEN ? AND ?", begin, end).Scan(&actives).Error; err != nil {
return nil, business.ErrStorage
}
collect(registers, &input.Registers)
collect(actives, &input.Actives)
} else {
for _, query := range []struct {
column string
target *[]business.ReportEvent
}{{"create_time", &input.Registers}, {"play_time", &input.Actives}} {
var rows []reportEventPO
if err := db.Table("tab_user_play_info").Select("user_id,source_db,promote_id,game_id,server_id").Where("server_id<>''").Where(query.column+" BETWEEN ? AND ?", begin, end).Scan(&rows).Error; err != nil {
return nil, business.ErrStorage
}
collect(rows, query.target)
}
}
pay := db.Table("tab_spend").Where("pay_status=1 AND pay_time BETWEEN ? AND ?", begin, end)
columns := "user_id,source_db,promote_id,game_id,pay_amount"
if kind == business.ServerReport {
pay = pay.Where("server_id<>''")
columns += ",server_id"
}
var pays []reportEventPO
if err := pay.Select(columns).Scan(&pays).Error; err != nil {
return nil, business.ErrStorage
}
collect(pays, &input.Payments)
ids := make([]int64, 0, len(userIDs))
for id := range userIDs {
ids = append(ids, id)
}
for start := 0; start < len(ids); start += 500 {
var users []struct {
ID int64
SourceDB string `gorm:"column:source_db"`
Puid, PromoteID *int64
IDCard string `gorm:"column:idcard"`
}
if err := db.Table("tab_user").Select("id,source_db,puid,promote_id,idcard").Where("id IN ?", ids[start:min(start+500, len(ids))]).Scan(&users).Error; err != nil {
return nil, business.ErrStorage
}
for _, po := range users {
key := business.ReportUserKey{SourceDB: po.SourceDB, ID: po.ID}
input.Users[key] = business.ReportUser{ReportUserKey: key, ParentID: po.Puid, PromoteID: po.PromoteID, IDCard: po.IDCard}
}
}
ids = make([]int64, 0, len(gameIDs))
for id := range gameIDs {
ids = append(ids, id)
}
for start := 0; start < len(ids); start += 500 {
var games []struct {
ID int64
SourceDB string `gorm:"column:source_db"`
GameName string
}
if err := db.Table("tab_game").Select("id,source_db,game_name").Where("id IN ?", ids[start:min(start+500, len(ids))]).Scan(&games).Error; err != nil {
return nil, business.ErrStorage
}
for _, po := range games {
if strings.TrimSpace(po.SourceDB) != "" && strings.TrimSpace(po.GameName) != "" {
input.Games[business.AnalyticsKey{SourceDB: po.SourceDB, ID: po.ID}] = po.GameName
}
}
}
return input, nil
}
+103
View File
@@ -0,0 +1,103 @@
package business
import (
"context"
"kra/internal/biz/business"
"testing"
"time"
)
func TestReportBuildStorageReplacesBothTablesAtomically(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
day := time.Date(2026, 11, 4, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
for _, sql := range []string{
"CREATE TABLE tab_user(id INTEGER,source_db TEXT,puid INTEGER,promote_id INTEGER,fgame_id INTEGER,idcard TEXT,register_time TEXT)",
"CREATE TABLE tab_user_day_login(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,login_time INTEGER)",
"CREATE TABLE tab_user_play_info(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,server_id TEXT,create_time INTEGER,play_time INTEGER)",
"CREATE TABLE tab_spend(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,server_id TEXT,pay_time INTEGER,pay_status INTEGER,pay_amount DECIMAL(20,2))",
"CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
"INSERT INTO tab_game VALUES(1,'lx','当前游戏')",
"CREATE TABLE tab_sw_report_day(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,new_register_cnt INTEGER,active_cnt INTEGER,pay_cnt INTEGER,new_pay_cnt INTEGER,total_pay DECIMAL(20,2))",
"CREATE TABLE tab_sw_report_day_key(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,metric INTEGER,identity_key TEXT)",
"CREATE TABLE tab_sw_report_server_day(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,server_num TEXT,dedup_mode INTEGER,new_register_cnt INTEGER,active_cnt INTEGER,pay_cnt INTEGER,total_pay DECIMAL(20,2))",
"CREATE TABLE tab_sw_report_server_day_key(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,server_num TEXT,dedup_mode INTEGER,metric INTEGER,identity_key TEXT)",
} {
if err := db.Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
for _, sql := range []string{
"INSERT INTO tab_user VALUES(1,'lx',0,10,1,'cardX',?),(2,'lx',1,10,1,'sub',?),(3,'lx',NULL,10,1,'null',?)",
} {
if err := db.Exec(sql, day.Unix(), day.Unix(), day.Unix()).Error; err != nil {
t.Fatal(err)
}
}
for _, sql := range []string{
"INSERT INTO tab_user_day_login VALUES(1,'lx',NULL,1,?),(2,'lx',10,1,?),(3,'lx',10,1,?)",
"INSERT INTO tab_spend VALUES(1,'lx',10,1,'001',?,1,10),(2,'lx',10,1,'001',?,1,20),(3,'lx',10,1,'001',?,0,999)",
} {
if err := db.Exec(sql, day.Unix(), day.Unix(), day.Unix()).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Exec("INSERT INTO tab_user_play_info VALUES(2,'lx',10,1,' 001 ',?,?)", day.Unix(), day.Unix()).Error; err != nil {
t.Fatal(err)
}
uc := business.NewReportBuildUsecase(NewReportBuildRepo(testData{db}))
run := func(kind business.ReportBuildKind) error {
return uc.Run(ctx, kind, "backfill", "2026-11-04", "2026-11-04")
}
if err := run(business.DailyReport); err != nil {
t.Fatal(err)
}
var counts []struct {
DedupMode, NewRegisterCnt, ActiveCnt, PayCnt, NewPayCnt int
TotalPay string
GameName string
}
if err := db.Table("tab_sw_report_day").Order("dedup_mode").Scan(&counts).Error; err != nil {
t.Fatal(err)
}
if len(counts) != 2 || counts[0].NewRegisterCnt != 1 || counts[0].ActiveCnt != 1 || counts[0].PayCnt != 2 || counts[0].NewPayCnt != 1 || portraitTestMoney(counts[0].TotalPay) != "30.00" || counts[0].GameName != "当前游戏" {
t.Fatalf("日报主账号/付费/金额规则错误: %+v", counts)
}
if err := run(business.ServerReport); err != nil {
t.Fatal(err)
}
var server struct {
NewRegisterCnt, ActiveCnt, PayCnt int
ServerNum string
}
if err := db.Table("tab_sw_report_server_day").Where("dedup_mode=0").Take(&server).Error; err != nil {
t.Fatal(err)
}
if server.NewRegisterCnt != 1 || server.ActiveCnt != 1 || server.PayCnt != 2 || server.ServerNum != "001" {
t.Fatalf("区服创角应包含子账号: %+v", server)
}
if err := run(business.DailyReport); err != nil {
t.Fatal(err)
}
var before int64
if err := db.Table("tab_sw_report_day").Where("stat_date=?", "2026-11-04").Count(&before).Error; err != nil || before != 2 {
t.Fatal("回跑日期必须保持自然日格式且不能重复累积行")
}
db.Table("tab_sw_report_day_key").Count(&before)
if err := db.Exec("CREATE TRIGGER reject_report_key BEFORE INSERT ON tab_sw_report_day_key BEGIN SELECT RAISE(ABORT,'测试写入失败'); END").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("UPDATE tab_spend SET pay_amount=100 WHERE user_id=1").Error; err != nil {
t.Fatal(err)
}
if err := run(business.DailyReport); err == nil {
t.Fatal("身份键写入失败必须回滚")
}
var after int64
db.Table("tab_sw_report_day_key").Count(&after)
var amount string
db.Table("tab_sw_report_day").Select("total_pay").Where("dedup_mode=0").Scan(&amount)
if before != after || portraitTestMoney(amount) != "30.00" {
t.Fatal("失败后丢失原身份键或覆盖原汇总")
}
}
+1
View File
@@ -46,6 +46,7 @@ var ProviderSet = wire.NewSet(
databusiness.NewServerAnalysisRepo,
databusiness.NewWorkbenchRepo,
databusiness.NewRankingRepo,
databusiness.NewReportBuildRepo,
NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
+2 -2
View File
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err)
}
if versions != 21 {
t.Fatalf("migration versions = %d, want 21", versions)
if versions != 23 {
t.Fatalf("migration versions = %d, want 23", versions)
}
var communicationRows []dataintegration.ConfigPO
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
+14 -14
View File
@@ -9,20 +9,20 @@ import (
func Definition() module.Definition {
return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{
Menus: []module.Menu{
{Name: "sw_RankingAnalysis", Path: "RankingAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/RankingAnalysis.vue", Title: "排行榜", Icon: "star", Sort: 6},
{Name: "sw_ServerAnalysis", Path: "ServerAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/ServerAnalysis.vue", Title: "区服分析", Icon: "data-analysis", Sort: 5},
{Name: "sw_ChannelDataStatistics", Path: "ChannelDataStatistics", ParentName: "sw_DataAnalysis", Component: "view/sw/ChannelDataStatistics.vue", Title: "渠道数据统计", Icon: "data-analysis", Sort: 1},
{Name: "sw_GameAnalytics", Path: "GameAnalytics", ParentName: "sw_DataAnalysis", Component: "view/sw/GameAnalytics.vue", Title: "游戏数据", Icon: "data-analysis", Sort: 4},
{Name: "sw_DataAnalysis", Path: "sw-DataAnalysis", Component: "view/routerHolder.vue", Title: "数据分析", Icon: "data-analysis", Sort: 3},
{Name: "sw_RegistrationDetails", Path: "RegistrationDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RegistrationDetails.vue", Title: "注册明细", Icon: "user", Sort: 3},
{Name: "sw_RechargeDetails", Path: "RechargeDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RechargeDetails.vue", Title: "充值明细", Icon: "money", Sort: 2},
{Name: "sw_ChannelList", Path: "ChannelList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelList.vue", Title: "渠道列表", Icon: "company", Sort: 2},
{Name: "sw_ChannelActivationList", Path: "ChannelActivationList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelActivationList.vue", Title: "渠道激活", Icon: "company", Sort: 3},
{Name: "sw_AlertList", Path: "AlertList", ParentName: "sw_AlertCenter", Component: "view/sw/AlertList.vue", Title: "预警列表", Icon: "warn", Sort: 1},
{Name: "sw_AlertCenter", Path: "sw-AlertCenter", Component: "view/routerHolder.vue", Title: "预警中心", Icon: "warn", Sort: 4},
{Name: "sw_AlertRules", Path: "AlertRules", ParentName: "sw_AlertCenter", Component: "view/sw/AlertRules.vue", Title: "预警规则", Icon: "warn", Sort: 2},
{Name: "sw_BusMgmt", Path: "sw-BusMgmt", Component: "view/routerHolder.vue", Title: "商务管理", Icon: "user", Sort: 2},
{Name: "sw_BusinessList", Path: "BusinessList", ParentName: "sw_BusMgmt", Component: "view/sw/BusinessList.vue", Title: "商务列表", Icon: "user", Sort: 1},
{Name: "sw_RankingAnalysis", Path: "RankingAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/RankingAnalysis.vue", Title: "排行榜", Icon: databusiness.MenuIcon("sw_RankingAnalysis"), Sort: 6},
{Name: "sw_ServerAnalysis", Path: "ServerAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/ServerAnalysis.vue", Title: "区服分析", Icon: databusiness.MenuIcon("sw_ServerAnalysis"), Sort: 5},
{Name: "sw_ChannelDataStatistics", Path: "ChannelDataStatistics", ParentName: "sw_DataAnalysis", Component: "view/sw/ChannelDataStatistics.vue", Title: "渠道数据统计", Icon: databusiness.MenuIcon("sw_ChannelDataStatistics"), Sort: 1},
{Name: "sw_GameAnalytics", Path: "GameAnalytics", ParentName: "sw_DataAnalysis", Component: "view/sw/GameAnalytics.vue", Title: "游戏数据", Icon: databusiness.MenuIcon("sw_GameAnalytics"), Sort: 4},
{Name: "sw_DataAnalysis", Path: "sw-DataAnalysis", Component: "view/routerHolder.vue", Title: "数据分析", Icon: databusiness.MenuIcon("sw_DataAnalysis"), Sort: 3},
{Name: "sw_RegistrationDetails", Path: "RegistrationDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RegistrationDetails.vue", Title: "注册明细", Icon: databusiness.MenuIcon("sw_RegistrationDetails"), Sort: 3},
{Name: "sw_RechargeDetails", Path: "RechargeDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RechargeDetails.vue", Title: "充值明细", Icon: databusiness.MenuIcon("sw_RechargeDetails"), Sort: 2},
{Name: "sw_ChannelList", Path: "ChannelList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelList.vue", Title: "渠道列表", Icon: databusiness.MenuIcon("sw_ChannelList"), Sort: 2},
{Name: "sw_ChannelActivationList", Path: "ChannelActivationList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelActivationList.vue", Title: "渠道激活", Icon: databusiness.MenuIcon("sw_ChannelActivationList"), Sort: 3},
{Name: "sw_AlertList", Path: "AlertList", ParentName: "sw_AlertCenter", Component: "view/sw/AlertList.vue", Title: "预警列表", Icon: databusiness.MenuIcon("sw_AlertList"), Sort: 1},
{Name: "sw_AlertCenter", Path: "sw-AlertCenter", Component: "view/routerHolder.vue", Title: "预警中心", Icon: databusiness.MenuIcon("sw_AlertCenter"), Sort: 4},
{Name: "sw_AlertRules", Path: "AlertRules", ParentName: "sw_AlertCenter", Component: "view/sw/AlertRules.vue", Title: "预警规则", Icon: databusiness.MenuIcon("sw_AlertRules"), Sort: 2},
{Name: "sw_BusMgmt", Path: "sw-BusMgmt", Component: "view/routerHolder.vue", Title: "商务管理", Icon: databusiness.MenuIcon("sw_BusMgmt"), Sort: 2},
{Name: "sw_BusinessList", Path: "BusinessList", ParentName: "sw_BusMgmt", Component: "view/sw/BusinessList.vue", Title: "商务列表", Icon: databusiness.MenuIcon("sw_BusinessList"), Sort: 1},
},
APIs: surface.APIsForPrefix("/sw/", 0),
}}
+2 -2
View File
@@ -25,8 +25,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
}
}
if got := catalog.MigrationSteps(); len(got) != 21 {
t.Fatalf("module migrations = %d, want 21", len(got))
if got := catalog.MigrationSteps(); len(got) != 23 {
t.Fatalf("module migrations = %d, want 23", len(got))
}
var menus, apis int
for _, definition := range catalog.Definitions {
+43
View File
@@ -0,0 +1,43 @@
package worker
import (
"context"
"encoding/json"
"fmt"
"kra/internal/biz/business"
platformtask "kra/pkg/task"
)
// 只注册旧执行器对应方法,不创建或启用计划;迁移库保留原参数与禁用状态。
func (methods *TaskMethods) registerBusinessReports(registry *platformtask.Registry) {
for _, job := range []struct {
name, description, period string
kind business.ReportBuildKind
}{
{"swReportDayTodayTask", "重建当日商务日汇总", "today", business.DailyReport},
{"swReportDayYesterdayTask", "重建昨日商务日汇总", "yesterday", business.DailyReport},
{"swReportDayBackfillTask", "按日期区间重建商务日汇总", "backfill", business.DailyReport},
{"swReportServerDayTodayTask", "重建当日区服日汇总", "today", business.ServerReport},
{"swReportServerDayYesterdayTask", "重建昨日区服日汇总", "yesterday", business.ServerReport},
{"swReportServerDayBackfillTask", "按日期区间重建区服日汇总", "backfill", business.ServerReport},
} {
registry.Register(platformtask.Method{Name: job.name, Description: job.description, Run: func(ctx context.Context, params json.RawMessage) error {
if methods.reports == nil {
return fmt.Errorf("商务汇总任务依赖未就绪")
}
if ctx == nil {
ctx = context.Background()
}
var args struct {
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
}
if job.period == "backfill" && len(params) > 0 {
if err := json.Unmarshal(params, &args); err != nil {
return fmt.Errorf("日期回跑参数必须是 JSON 对象: %w", err)
}
}
return methods.reports.Run(ctx, job.kind, job.period, args.StartDate, args.EndDate)
}})
}
}
+5 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"kra/internal/biz/business"
"kra/internal/biz/system"
taskbiz "kra/internal/biz/task"
@@ -18,16 +19,18 @@ type TaskMethods struct {
maintenance *system.MaintenanceUsecase
media *system.MediaUsecase
runtime *config.Store
reports *business.ReportBuildUsecase
}
func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceUsecase, media *system.MediaUsecase, runtime *config.Store) *TaskMethods {
return &TaskMethods{tasks: tasks, maintenance: maintenance, media: media, runtime: runtime}
func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceUsecase, media *system.MediaUsecase, runtime *config.Store, reports *business.ReportBuildUsecase) *TaskMethods {
return &TaskMethods{tasks: tasks, maintenance: maintenance, media: media, runtime: runtime, reports: reports}
}
func (methods *TaskMethods) RegisterTasks(registry *platformtask.Registry) {
if methods == nil || registry == nil {
return
}
methods.registerBusinessReports(registry)
descriptions := map[string]string{}
for _, task := range taskmodule.TimedTasks() {
descriptions[task.MethodName] = task.Description