feat: integrate OA activation workflows and business migration
This commit is contained in:
@@ -12,7 +12,8 @@ import (
|
||||
|
||||
// accountPO 是 sys_users 的商务投影,表结构仍由 system 模块维护。
|
||||
type accountPO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Remark string `gorm:"size:500"`
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt
|
||||
UUID, Username, Password, NickName, RealName, Phone, Email string
|
||||
|
||||
@@ -2,6 +2,7 @@ package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -12,6 +13,9 @@ import (
|
||||
|
||||
// OA 表只读,不由新商务的系统迁移创建或改写。
|
||||
type activationPO struct {
|
||||
BusierID uint64
|
||||
CreatedAt *time.Time
|
||||
RelatedChannels string
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
PromoteID *uint64
|
||||
PlatformName, PromoteAccount, BusierAccount, ApplicantOA, Status string
|
||||
@@ -25,7 +29,20 @@ func toBizActivation(po activationPO) *business.Activation {
|
||||
if po.AccumAmount != nil {
|
||||
amount = *po.AccumAmount
|
||||
}
|
||||
return &business.Activation{ID: po.ID, PromoteID: po.PromoteID, PlatformName: po.PlatformName, PromoteAccount: po.PromoteAccount, BusierAccount: po.BusierAccount, ApplicantOA: po.ApplicantOA, Status: po.Status, AccumAmount: amount, BindAt: po.BindAt, UnbindAt: po.UnbindAt, ReachAt: po.ReachAt}
|
||||
row := &business.Activation{BusierID: po.BusierID, CreatedAt: po.CreatedAt, ID: po.ID, PromoteID: po.PromoteID, PlatformName: po.PlatformName, PromoteAccount: po.PromoteAccount, BusierAccount: po.BusierAccount, ApplicantOA: po.ApplicantOA, Status: po.Status, AccumAmount: amount, BindAt: po.BindAt, UnbindAt: po.UnbindAt, ReachAt: po.ReachAt}
|
||||
var channels []struct {
|
||||
PlatformName string `json:"platformName"`
|
||||
PromoteID uint64 `json:"promoteId"`
|
||||
}
|
||||
if po.RelatedChannels != "" {
|
||||
if json.Unmarshal([]byte(po.RelatedChannels), &channels) != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for _, ch := range channels {
|
||||
row.Channels = append(row.Channels, business.ChannelKey{SourceDB: ch.PlatformName, ID: ch.PromoteID})
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
type activationRepo struct {
|
||||
@@ -99,7 +116,41 @@ func (r *activationRepo) List(ctx context.Context, scope business.ActivationScop
|
||||
return nil, business.ErrActivationSource
|
||||
}
|
||||
for _, po := range pos {
|
||||
page.Rows = append(page.Rows, toBizActivation(po))
|
||||
row := toBizActivation(po)
|
||||
if row == nil {
|
||||
return nil, business.ErrActivationSource
|
||||
}
|
||||
if row.BindAt != nil {
|
||||
if err := r.applyTransferEnd(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := activationMetricQuery(row)
|
||||
available, err := r.activationMetricsAvailable(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if available {
|
||||
items, err := r.Spend(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
amounts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
amounts = append(amounts, item.Amount)
|
||||
}
|
||||
total, err := sumActivationMoney(amounts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.AccumAmount = total
|
||||
row.MetricsAvailable = true
|
||||
}
|
||||
row.RegisteredPlayers, err = r.registered(ctx, query, filter.IDCardDedup)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
page.Rows = append(page.Rows, row)
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
@@ -116,49 +167,12 @@ func (r *activationRepo) Find(ctx context.Context, id uint64) (*business.Activat
|
||||
}
|
||||
return nil, business.ErrActivationSource
|
||||
}
|
||||
return toBizActivation(po), nil
|
||||
}
|
||||
|
||||
func (r *activationRepo) Spend(ctx context.Context, query business.ActivationSpendQuery) ([]*business.ActivationSpendItem, error) {
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
available, err := businessTableAvailable(db, "tab_promote")
|
||||
if err != nil {
|
||||
row := toBizActivation(po)
|
||||
if row == nil {
|
||||
return nil, business.ErrActivationSource
|
||||
}
|
||||
if err := r.applyTransferEnd(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
return nil, business.ErrActivationMetrics
|
||||
}
|
||||
tree := func() *gorm.DB {
|
||||
return db.Table("tab_promote p").Where("p.source_db = ? AND (p.id = ? OR p.top_promote_id = ? OR p.parent_id = ?)", query.PlatformName, query.PromoteID, query.PromoteID, query.PromoteID)
|
||||
}
|
||||
var count int64
|
||||
if err := tree().Count(&count).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
items := []*business.ActivationSpendItem{}
|
||||
if count == 0 {
|
||||
return items, nil
|
||||
}
|
||||
available, err = businessTableAvailable(db, "tab_spend")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
return nil, business.ErrActivationMetrics
|
||||
}
|
||||
q := db.Table("tab_spend s").Where("s.source_db = ? AND s.pay_status = ? AND s.pay_time >= ?", query.PlatformName, 1, query.Start).Where("EXISTS (?)", tree().Select("1").Where("p.id=s.promote_id"))
|
||||
if query.End != nil {
|
||||
q = q.Where("s.pay_time <= ?", *query.End)
|
||||
}
|
||||
var rows []struct {
|
||||
GameID uint64
|
||||
GameName, Amount string
|
||||
}
|
||||
if err := q.Select("s.game_id,COALESCE(MAX(s.game_name),'') AS game_name,COALESCE(SUM(s.pay_amount),0) AS amount").Group("s.game_id").Order("amount DESC,s.game_id").Scan(&rows).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
for _, row := range rows {
|
||||
items = append(items, &business.ActivationSpendItem{GameName: row.GameName, Amount: row.Amount})
|
||||
}
|
||||
return items, nil
|
||||
return row, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type activationTestSources map[string]*gorm.DB
|
||||
|
||||
func (s activationTestSources) Database(name string) (*gorm.DB, error) {
|
||||
db := s[name]
|
||||
if db == nil {
|
||||
return nil, errors.New("missing source")
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func TestActivationCrossPlatformMetricsAndIdentityDedup(t *testing.T) {
|
||||
db, oa, repo := prepareActivations(t)
|
||||
for _, sql := range []string{
|
||||
"CREATE TABLE tab_user(id INTEGER,source_db TEXT,promote_id INTEGER,puid INTEGER,register_time TEXT,idcard TEXT)",
|
||||
"INSERT INTO tab_user VALUES(1,'lx',100,0,'1000','same'),(2,'lx',101,0,'1500','same'),(1,'hw',100,0,'1500','same'),(3,'lx',102,0,'1600',''),(4,'lx',103,0,'1600','outside'),(5,'lx',100,1,'1600','child-account'),(6,'lx',100,0,'999','before'),(7,'lx',100,0,'2001','after')",
|
||||
} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := oa.Model(&activationPO{}).Where("id=1").Update("related_channels", `[{"platformName":"lx","promoteId":100},{"platformName":"hw","promoteId":100},{"platformName":"lx","promoteId":101}]`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo.(*activationRepo).databases = activationSDKFixtures(t, db, oa)
|
||||
page, err := repo.List(context.Background(), business.ActivationScope{All: true}, business.ActivationFilter{Statuses: []string{"reached"}, IDCardDedup: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := page.Rows[0]
|
||||
if row.RegisteredPlayers == nil || *row.RegisteredPlayers != 1 || row.AccumAmount != "1032.60" {
|
||||
t.Fatalf("cross platform dedup/tree overlap: %+v", row)
|
||||
}
|
||||
page, err = repo.List(context.Background(), business.ActivationScope{All: true}, business.ActivationFilter{Statuses: []string{"reached"}})
|
||||
if err != nil || *page.Rows[0].RegisteredPlayers != 4 {
|
||||
t.Fatalf("account count: %+v %v", page, err)
|
||||
}
|
||||
if err := oa.Model(&activationPO{}).Where("id=1").Update("unbind_at", nil).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err = repo.List(context.Background(), business.ActivationScope{All: true}, business.ActivationFilter{Statuses: []string{"reached"}})
|
||||
if err != nil || page.Rows[0].AccumAmount != "1732.60" || *page.Rows[0].RegisteredPlayers != 5 {
|
||||
t.Fatalf("reached should keep accumulating: %+v %v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivationPoolFiltersAndRedactedProjection(t *testing.T) {
|
||||
db, oa, _ := prepareActivations(t)
|
||||
if err := oa.AutoMigrate(&activationPoolPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, sql := range []string{"ALTER TABLE tab_promote ADD COLUMN lock_status INTEGER", "ALTER TABLE tab_promote ADD COLUMN last_login_time INTEGER", "ALTER TABLE tab_promote ADD COLUMN create_time INTEGER", "UPDATE tab_promote SET status=1,lock_status=0,create_time=1000"} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, sql := range []string{"ALTER TABLE sys_promote_active_pool_bind ADD COLUMN pool_record_id INTEGER", "ALTER TABLE sys_promote_active_pool_bind ADD COLUMN reject_reason TEXT", "ALTER TABLE sys_promote_active_pool_bind ADD COLUMN review_at DATETIME"} {
|
||||
if err := oa.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
rows := []activationPoolPO{{ID: 1, PlatformName: "lx", PromoteID: 100, PromoteAccount: "Open", RegisterType: 1, CreatedAt: now}, {ID: 2, PlatformName: "hw", PromoteID: 100, PromoteAccount: "Claimed", ClaimFlag: 1, CreatedAt: now}, {ID: 3, PlatformName: "lx", PromoteID: 103, PromoteAccount: "Removed", DeletedAt: &now, CreatedAt: now}}
|
||||
if err := oa.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := NewActivationPoolRepo(testData{db}, activationSDKFixtures(t, db, oa))
|
||||
page, err := r.Pool(context.Background(), business.ActivationPoolFilter{Name: "Open", ChannelType: "公会", Status: "正常"}, business.ListLimit(10))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 1 || page.Rows[0].Name != "Open" || page.Rows[0].Status != "正常" {
|
||||
t.Fatalf("pool filters: %+v", page)
|
||||
}
|
||||
if _, err := r.PoolChannel(context.Background(), 2); err != business.ErrActivationNotFound {
|
||||
t.Fatal("claimed pool may not be applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardEventsUseTimeAndHistoricalOwner(t *testing.T) {
|
||||
db := prepareAccounts(t)
|
||||
sdk := openTestDB(t)
|
||||
if err := sdk.AutoMigrate(&channelEventPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO sys_users(id,username,level,id_path) VALUES(7,'owner',3,'0'); INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(7,'lx',10,'0')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
begin := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
|
||||
w := business.DashboardWindow{Begin: begin, End: begin, PreviousBegin: begin.AddDate(0, 0, -1), PreviousEnd: begin.AddDate(0, 0, -1)}
|
||||
events := []channelEventPO{{ID: 1, PromoteID: 1, PromoteLevel: 1, Kind: "locked", OldBusierID: 10, OccurredAt: begin}, {ID: 2, PromoteID: 1, PromoteLevel: 1, Kind: "locked", OldBusierID: 10, OccurredAt: begin.Add(time.Hour)}, {ID: 3, PromoteID: 2, PromoteLevel: 2, Kind: "terminated", OldBusierID: 10, OccurredAt: begin}, {ID: 4, PromoteID: 3, PromoteLevel: 1, Kind: "locked", OldBusierID: 20, OccurredAt: begin}, {ID: 5, PromoteID: 1, PromoteLevel: 1, Kind: "locked", OldBusierID: 10, OccurredAt: begin.Add(-time.Hour)}, {ID: 6, PromoteID: 1, PromoteLevel: 1, Kind: "terminated", OldBusierID: 10, OccurredAt: begin.AddDate(0, 0, 1)}}
|
||||
if err := sdk.Create(&events).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &dashboardRepo{data: testData{db}, databases: activationTestSources{"lx": sdk}}
|
||||
out := &business.DashboardData{}
|
||||
if err := r.events(context.Background(), business.AnalyticsScope{Viewer: business.UserScope{OnlySelf: true, SelfID: 7}, IncludeSub: true}, []string{"lx"}, w, out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !out.EventsAvailable || out.Current.LockedChannels != 1 || out.Previous.LockedChannels != 1 || out.Current.TerminatedChannels != 1 {
|
||||
t.Fatalf("events: %+v", out)
|
||||
}
|
||||
if err := sdk.Create(&channelEventPO{Kind: "coverage", OccurredAt: begin}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
incomplete := &business.DashboardData{}
|
||||
if err := r.events(context.Background(), business.AnalyticsScope{Viewer: business.UserScope{All: true}}, []string{"lx"}, w, incomplete); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if incomplete.EventsAvailable || !incomplete.EventsIncomplete {
|
||||
t.Fatal("missing historical coverage was reported as real zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivationStopsAtExternalTransferBeforeOAScan(t *testing.T) {
|
||||
db, oa, _ := prepareActivations(t)
|
||||
sources := activationSDKFixtures(t, db, oa)
|
||||
sdk := sources["lx"]
|
||||
if err := sdk.AutoMigrate(&channelEventPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
end := time.Unix(1600, 0).UTC()
|
||||
if err := oa.Model(&activationPO{}).Where("id=1").Updates(map[string]any{"reach_at": time.Unix(1200, 0).UTC(), "unbind_at": nil}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sdk.Create(&channelEventPO{Kind: "transfer", PromoteID: 100, OldBusierAccount: "Own", OccurredAt: end}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewActivationRepo(testData{db}, sources)
|
||||
row, err := repo.Find(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.EndAt() == nil || !row.EndAt().Equal(end) {
|
||||
t.Fatal("old owner can read past transfer")
|
||||
}
|
||||
items, err := repo.Spend(context.Background(), activationMetricQuery(row))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values := []string{}
|
||||
for _, item := range items {
|
||||
values = append(values, item.Amount)
|
||||
}
|
||||
sum, err := sumActivationMoney(values)
|
||||
if err != nil || sum != "13.40" {
|
||||
t.Fatalf("post-transfer spend leaked: %s %v", sum, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
"math/big"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (r *activationRepo) applyTransferEnd(ctx context.Context, row *business.Activation) error {
|
||||
if row.BindAt == nil || row.PromoteID == nil || (row.Status != "reached" && row.ReachAt == nil) {
|
||||
return nil
|
||||
}
|
||||
db, err := r.databases.Database(row.PlatformName)
|
||||
if err != nil || db == nil {
|
||||
return nil
|
||||
}
|
||||
db = db.WithContext(ctx)
|
||||
ok, err := businessTableAvailable(db, "tab_promote_business_event")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
begin := *row.BindAt
|
||||
if row.ReachAt != nil && row.ReachAt.After(begin) {
|
||||
begin = *row.ReachAt
|
||||
}
|
||||
q := db.Table("tab_promote_business_event").Where("kind='transfer' AND promote_id=? AND occurred_at>=?", *row.PromoteID, begin)
|
||||
if row.BusierID > 0 {
|
||||
q = q.Where("old_busier_id=?", row.BusierID)
|
||||
} else {
|
||||
q = q.Where("LOWER(old_busier_account)=LOWER(?)", row.BusierAccount)
|
||||
}
|
||||
var rows []struct{ OccurredAt time.Time }
|
||||
if err := q.Select("occurred_at").Order("occurred_at,id").Limit(1).Find(&rows).Error; err != nil {
|
||||
return business.ErrActivationSource
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
row.EndOnTransfer(rows[0].OccurredAt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func activationMetricQuery(row *business.Activation) business.ActivationSpendQuery {
|
||||
q := business.ActivationSpendQuery{PlatformName: row.PlatformName, Channels: row.Channels, Start: row.BindAt.Unix()}
|
||||
if row.PromoteID != nil {
|
||||
q.PromoteID = *row.PromoteID
|
||||
}
|
||||
if end := row.EndAt(); end != nil {
|
||||
n := end.Unix()
|
||||
q.End = &n
|
||||
}
|
||||
return q
|
||||
}
|
||||
func sumActivationMoney(values []string) (string, error) {
|
||||
total := new(big.Rat)
|
||||
for _, v := range values {
|
||||
n, ok := new(big.Rat).SetString(v)
|
||||
if !ok {
|
||||
return "", business.ErrStorage
|
||||
}
|
||||
total.Add(total, n)
|
||||
}
|
||||
return total.FloatString(2), nil
|
||||
}
|
||||
|
||||
// 激活数据与 OA 使用同一 SDK 数据源,避免商务报表副本延迟/测试副本不同导致金额不一致。
|
||||
func activationRoots(q business.ActivationSpendQuery) map[string][]uint64 {
|
||||
roots := map[string][]uint64{}
|
||||
keys := q.Channels
|
||||
if len(keys) == 0 {
|
||||
keys = []business.ChannelKey{{SourceDB: q.PlatformName, ID: q.PromoteID}}
|
||||
}
|
||||
for _, key := range keys {
|
||||
if key.ID > 0 {
|
||||
roots[key.SourceDB] = append(roots[key.SourceDB], key.ID)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
func (r *activationRepo) activationMetricsAvailable(ctx context.Context, q business.ActivationSpendQuery) (bool, error) {
|
||||
for source := range activationRoots(q) {
|
||||
db, err := r.databases.Database(source)
|
||||
if err != nil || db == nil {
|
||||
return false, nil
|
||||
}
|
||||
ok, err := businessTablesAvailable(db.WithContext(ctx), "tab_promote", "tab_spend")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
func nativeActivationTree(db *gorm.DB, roots []uint64) *gorm.DB {
|
||||
return db.Table("tab_promote p").Where("p.id IN ? OR p.top_promote_id IN ? OR p.parent_id IN ?", roots, roots, roots)
|
||||
}
|
||||
func (r *activationRepo) Spend(ctx context.Context, q business.ActivationSpendQuery) ([]*business.ActivationSpendItem, error) {
|
||||
out := []*business.ActivationSpendItem{}
|
||||
for source, roots := range activationRoots(q) {
|
||||
db, err := r.databases.Database(source)
|
||||
if err != nil || db == nil {
|
||||
return nil, business.ErrActivationMetrics
|
||||
}
|
||||
db = db.WithContext(ctx)
|
||||
tree := nativeActivationTree(db, roots)
|
||||
var n int64
|
||||
if err := tree.Count(&n).Error; err != nil {
|
||||
return nil, business.ErrActivationMetrics
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
query := db.Table("tab_spend s").Where("s.pay_status=1 AND s.pay_time>=?", q.Start).Where("EXISTS (?)", tree.Select("1").Where("p.id=s.promote_id"))
|
||||
if q.End != nil {
|
||||
query = query.Where("s.pay_time<=?", *q.End)
|
||||
}
|
||||
var rows []struct {
|
||||
GameID uint64
|
||||
GameName, Amount string
|
||||
}
|
||||
if err := query.Select("s.game_id,COALESCE(MAX(s.game_name),'') AS game_name,COALESCE(SUM(s.pay_amount),0) AS amount").Group("s.game_id").Order("s.game_id").Scan(&rows).Error; err != nil {
|
||||
return nil, business.ErrActivationMetrics
|
||||
}
|
||||
for _, row := range rows {
|
||||
out = append(out, &business.ActivationSpendItem{PlatformName: source, GameName: row.GameName, Amount: row.Amount})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
a, okA := new(big.Rat).SetString(out[i].Amount)
|
||||
b, okB := new(big.Rat).SetString(out[j].Amount)
|
||||
if okA && okB && a.Cmp(b) != 0 {
|
||||
return a.Cmp(b) > 0
|
||||
}
|
||||
if out[i].PlatformName != out[j].PlatformName {
|
||||
return out[i].PlatformName < out[j].PlatformName
|
||||
}
|
||||
return out[i].GameName < out[j].GameName
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
func (r *activationRepo) registered(ctx context.Context, q business.ActivationSpendQuery, dedup bool) (*int64, error) {
|
||||
total := int64(0)
|
||||
identities := map[string]bool{}
|
||||
for source, roots := range activationRoots(q) {
|
||||
db, err := r.databases.Database(source)
|
||||
if err != nil || db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
db = db.WithContext(ctx)
|
||||
ok, err := businessTablesAvailable(db, "tab_user", "tab_promote")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
cast := "CAST(r.register_time AS INTEGER)"
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
cast = "CAST(r.register_time AS UNSIGNED)"
|
||||
}
|
||||
query := db.Table("tab_user r").Where("r.puid=0 AND "+cast+">=?", q.Start).Where("EXISTS (?)", nativeActivationTree(db, roots).Select("1").Where("p.id=r.promote_id"))
|
||||
if q.End != nil {
|
||||
query = query.Where(cast+"<=?", *q.End)
|
||||
}
|
||||
if dedup {
|
||||
var cards []string
|
||||
if err := query.Where("TRIM(COALESCE(r.idcard,''))<>''").Distinct("TRIM(r.idcard)").Pluck("TRIM(r.idcard)", &cards).Error; err != nil {
|
||||
return nil, business.ErrActivationMetrics
|
||||
}
|
||||
for _, card := range cards {
|
||||
identities[card] = true
|
||||
}
|
||||
} else {
|
||||
var count int64
|
||||
if err := query.Distinct("r.id").Count(&count).Error; err != nil {
|
||||
return nil, business.ErrActivationMetrics
|
||||
}
|
||||
total += count
|
||||
}
|
||||
}
|
||||
if dedup {
|
||||
total = int64(len(identities))
|
||||
}
|
||||
return &total, nil
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
type activationPoolPO struct {
|
||||
ID, PromoteID uint64
|
||||
PlatformName, PromoteAccount string
|
||||
RegisterType int
|
||||
ClaimFlag int
|
||||
CreatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
func (activationPoolPO) TableName() string { return "sys_platform_promote_active_pool" }
|
||||
|
||||
type activationPoolRepo struct {
|
||||
data Provider
|
||||
databases PlatformProvider
|
||||
}
|
||||
|
||||
func NewActivationPoolRepo(data Provider, databases PlatformProvider) business.ActivationPoolRepo {
|
||||
return &activationPoolRepo{data: data, databases: databases}
|
||||
}
|
||||
func (r *activationPoolRepo) oa(ctx context.Context) (*gorm.DB, error) {
|
||||
db, err := r.databases.Database("oa")
|
||||
if err != nil || db == nil {
|
||||
return nil, business.ErrActivationSource
|
||||
}
|
||||
db = db.WithContext(ctx)
|
||||
ok, err := businessTableAvailable(db, "sys_platform_promote_active_pool")
|
||||
if err != nil || !ok {
|
||||
return nil, business.ErrActivationSource
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
func toBizPool(p activationPoolPO) *business.ActivationPoolChannel {
|
||||
kind := "个人"
|
||||
if p.RegisterType == 1 {
|
||||
kind = "公会"
|
||||
}
|
||||
return &business.ActivationPoolChannel{ID: p.ID, PromoteID: p.PromoteID, SourceDB: p.PlatformName, Name: p.PromoteAccount, ChannelType: kind, EnteredAt: p.CreatedAt}
|
||||
}
|
||||
func (r *activationPoolRepo) PoolChannel(ctx context.Context, id uint64) (*business.ActivationPoolChannel, error) {
|
||||
db, err := r.oa(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p activationPoolPO
|
||||
if err := db.Where("id=? AND claim_flag=0 AND deleted_at IS NULL", id).Take(&p).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, business.ErrActivationNotFound
|
||||
}
|
||||
return nil, business.ErrActivationSource
|
||||
}
|
||||
return toBizPool(p), nil
|
||||
}
|
||||
func (r *activationPoolRepo) BindingOptions(ctx context.Context, id uint) ([]*business.ActivationBindingOption, error) {
|
||||
var rows []struct {
|
||||
SourceDB, Account string
|
||||
ID uint64
|
||||
Level int
|
||||
}
|
||||
err := r.data.DB().WithContext(ctx).Table("sys_user_bind_platform b").Joins("JOIN sys_users u ON u.id=b.user_id AND u.deleted_at IS NULL").Where("b.user_id=? AND b.status='0' AND u.business_status=0 AND u.enable=1", id).Select("b.source_db,b.platform_busier_id AS id,b.platform_busier_account AS account,u.level").Order("b.source_db,b.platform_busier_id").Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
out := make([]*business.ActivationBindingOption, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
db, err := r.databases.Database(p.SourceDB)
|
||||
if err != nil || db == nil {
|
||||
return nil, business.ErrPlatformUnavailable
|
||||
}
|
||||
var sdk struct{ Level int }
|
||||
if err := db.WithContext(ctx).Table("tab_promote_business").Where("id=?", p.ID).Select("level").Take(&sdk).Error; err != nil {
|
||||
return nil, business.ErrPlatformUnavailable
|
||||
}
|
||||
p.Level = sdk.Level
|
||||
out = append(out, &business.ActivationBindingOption{SourceDB: p.SourceDB, Account: p.Account, ID: p.ID, Level: p.Level})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (r *activationPoolRepo) Pool(ctx context.Context, f business.ActivationPoolFilter, opts ...business.ListOption) (*business.ActivationPoolPage, error) {
|
||||
out := &business.ActivationPoolPage{Rows: []*business.ActivationPoolChannel{}, Available: true}
|
||||
db, err := r.oa(ctx)
|
||||
if err != nil {
|
||||
out.Available = false
|
||||
return out, nil
|
||||
}
|
||||
q := db.Model(&activationPoolPO{}).Where("deleted_at IS NULL AND claim_flag=0")
|
||||
if len(f.Sources) > 0 {
|
||||
q = q.Where("platform_name IN ?", f.Sources)
|
||||
}
|
||||
if f.Name != "" {
|
||||
q = q.Where("promote_account LIKE ?", "%"+f.Name+"%")
|
||||
}
|
||||
if f.ChannelType == "公会" {
|
||||
q = q.Where("register_type=1")
|
||||
} else if f.ChannelType == "个人" {
|
||||
q = q.Where("register_type<>1")
|
||||
}
|
||||
var pools []activationPoolPO
|
||||
if err := q.Order("created_at DESC,id DESC").Find(&pools).Error; err != nil {
|
||||
return nil, business.ErrActivationSource
|
||||
}
|
||||
if len(pools) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
bySource := map[string][]uint64{}
|
||||
ids := make([]uint64, 0, len(pools))
|
||||
for _, p := range pools {
|
||||
bySource[p.PlatformName] = append(bySource[p.PlatformName], p.PromoteID)
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
byKey := map[business.ChannelKey]channelPO{}
|
||||
for source, channelIDs := range bySource {
|
||||
sdk, err := r.databases.Database(source)
|
||||
if err != nil || sdk == nil {
|
||||
out.Available = false
|
||||
return out, nil
|
||||
}
|
||||
sdk = sdk.WithContext(ctx)
|
||||
for offset := 0; offset < len(channelIDs); offset += 500 {
|
||||
var channels []channelPO
|
||||
if err := sdk.Table("tab_promote").Select("id,status,lock_status,last_login_time,create_time").Where("id IN ?", channelIDs[offset:min(offset+500, len(channelIDs))]).Scan(&channels).Error; err != nil {
|
||||
return nil, business.ErrActivationSource
|
||||
}
|
||||
for _, ch := range channels {
|
||||
ch.SourceDB = source
|
||||
byKey[business.ChannelKey{SourceDB: source, ID: ch.ID}] = ch
|
||||
}
|
||||
}
|
||||
}
|
||||
var rejected []struct {
|
||||
PoolRecordID uint64
|
||||
RejectReason string
|
||||
ReviewAt *time.Time
|
||||
}
|
||||
if err := db.Table("sys_promote_active_pool_bind b").Select("b.pool_record_id,b.reject_reason,b.review_at").Where("b.pool_record_id IN ? AND b.deleted_at IS NULL AND b.status='rejected'", ids).Where("NOT EXISTS (?)", db.Table("sys_promote_active_pool_bind newer").Select("1").Where("newer.pool_record_id=b.pool_record_id AND newer.deleted_at IS NULL AND newer.id>b.id")).Scan(&rejected).Error; err != nil {
|
||||
return nil, business.ErrActivationSource
|
||||
}
|
||||
rejections := map[uint64]int{}
|
||||
for i, p := range rejected {
|
||||
rejections[p.PoolRecordID] = i
|
||||
}
|
||||
rows := []*business.ActivationPoolChannel{}
|
||||
now := time.Now().Unix()
|
||||
for _, p := range pools {
|
||||
row := toBizPool(p)
|
||||
ch, found := byKey[business.ChannelKey{SourceDB: p.PlatformName, ID: p.PromoteID}]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
row.Status = "正常"
|
||||
if (ch.Status != nil && *ch.Status == -2) || (ch.LockStatus != nil && *ch.LockStatus == -2) {
|
||||
row.Status = "解除合作"
|
||||
} else if (ch.Status != nil && *ch.Status == -1) || (ch.LockStatus != nil && *ch.LockStatus < 0) {
|
||||
row.Status = "锁定"
|
||||
}
|
||||
if f.Status != "" && row.Status != f.Status {
|
||||
continue
|
||||
}
|
||||
login := ch.LastLoginTime
|
||||
if login == nil || *login == 0 {
|
||||
login = ch.CreateTime
|
||||
}
|
||||
if login != nil {
|
||||
days := (now - *login) / 86400
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
row.NoLoginDays = &days
|
||||
}
|
||||
if i, ok := rejections[p.ID]; ok {
|
||||
row.Rejected = true
|
||||
row.RejectReason = rejected[i].RejectReason
|
||||
row.RejectedAt = rejected[i].ReviewAt
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
out.Total = int64(len(rows))
|
||||
o := business.NewListOptions(opts...)
|
||||
start := min(o.Offset, len(rows))
|
||||
end := len(rows)
|
||||
if o.Limit > 0 {
|
||||
end = min(end, start+o.Limit)
|
||||
}
|
||||
out.Rows = rows[start:end]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Event tables are written transactionally by OA in each platform database.
|
||||
type channelEventPO struct {
|
||||
ID, PromoteID uint64
|
||||
PromoteAccount, Kind string
|
||||
PromoteLevel int
|
||||
OccurredAt time.Time
|
||||
OldBusierID, NewBusierID uint64
|
||||
OldBusierAccount, NewBusierAccount, Operator, Reason string
|
||||
FromPool, ToPool bool
|
||||
}
|
||||
|
||||
func (channelEventPO) TableName() string { return "tab_promote_business_event" }
|
||||
func eventSources(sources []string) []string {
|
||||
if len(sources) == 0 {
|
||||
return []string{"lx", "hw", "yq"}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
func (r *activationPoolRepo) Churn(ctx context.Context, f business.ActivationPoolFilter, opts ...business.ListOption) (*business.ActivationChurnPage, error) {
|
||||
out := &business.ActivationChurnPage{Rows: []*business.ActivationChurn{}, Available: true}
|
||||
o := business.NewListOptions(opts...)
|
||||
for _, source := range eventSources(f.Sources) {
|
||||
db, err := r.databases.Database(source)
|
||||
if err != nil || db == nil {
|
||||
out.Available = false
|
||||
continue
|
||||
}
|
||||
db = db.WithContext(ctx)
|
||||
ok, err := businessTableAvailable(db, "tab_promote_business_event")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
out.Available = false
|
||||
continue
|
||||
}
|
||||
q := db.Model(&channelEventPO{}).Where("kind='transfer' AND from_pool=?", false)
|
||||
if f.Name != "" {
|
||||
q = q.Where("promote_account LIKE ?", "%"+f.Name+"%")
|
||||
}
|
||||
if f.Target != "" {
|
||||
if strings.Contains("渠道激活池", f.Target) {
|
||||
q = q.Where("(to_pool=? OR new_busier_account LIKE ?)", true, "%"+f.Target+"%")
|
||||
} else {
|
||||
q = q.Where("to_pool=? AND new_busier_account LIKE ?", false, "%"+f.Target+"%")
|
||||
}
|
||||
}
|
||||
if f.Begin != nil {
|
||||
q = q.Where("occurred_at>=?", *f.Begin)
|
||||
}
|
||||
if f.End != nil {
|
||||
q = q.Where("occurred_at<?", f.End.AddDate(0, 0, 1))
|
||||
}
|
||||
var n int64
|
||||
if err := q.Count(&n).Error; err != nil {
|
||||
return nil, business.ErrActivationSource
|
||||
}
|
||||
out.Total += n
|
||||
var events []channelEventPO
|
||||
if err := q.Order("occurred_at DESC,id DESC").Limit(o.Offset + o.Limit).Find(&events).Error; err != nil {
|
||||
return nil, business.ErrActivationSource
|
||||
}
|
||||
for _, e := range events {
|
||||
to := e.NewBusierAccount
|
||||
if e.ToPool {
|
||||
to = "渠道激活池"
|
||||
}
|
||||
out.Rows = append(out.Rows, &business.ActivationChurn{ID: e.ID, PromoteID: e.PromoteID, SourceDB: source, Name: e.PromoteAccount, From: e.OldBusierAccount, To: to, Reason: e.Reason, Operator: e.Operator, At: e.OccurredAt, ToPool: e.ToPool})
|
||||
}
|
||||
}
|
||||
if !out.Available {
|
||||
out.Rows = []*business.ActivationChurn{}
|
||||
out.Total = 0
|
||||
return out, nil
|
||||
}
|
||||
sort.Slice(out.Rows, func(i, j int) bool {
|
||||
a, b := out.Rows[i], out.Rows[j]
|
||||
if !a.At.Equal(b.At) {
|
||||
return a.At.After(b.At)
|
||||
}
|
||||
if a.SourceDB != b.SourceDB {
|
||||
return a.SourceDB < b.SourceDB
|
||||
}
|
||||
return a.ID > b.ID
|
||||
})
|
||||
start := min(o.Offset, len(out.Rows))
|
||||
end := len(out.Rows)
|
||||
if o.Limit > 0 {
|
||||
end = min(end, start+o.Limit)
|
||||
}
|
||||
out.Rows = out.Rows[start:end]
|
||||
return out, nil
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func prepareActivations(t *testing.T) (*gorm.DB, *gorm.DB, business.ActivationRe
|
||||
end := time.Unix(2000, 0).UTC()
|
||||
channel := uint64(100)
|
||||
amount := "30.30"
|
||||
pos := []activationPO{{ID: 1, PromoteID: &channel, PlatformName: "lx", PromoteAccount: "渠道一", BusierAccount: "Own", BindAt: &start, ReachAt: &end, Status: "reached", AccumAmount: &amount}, {ID: 2, PlatformName: "hw", BusierAccount: "own", Status: "pending"}, {ID: 3, PlatformName: "lx", BusierAccount: "Other", Status: "in_progress"}, {ID: 4, PlatformName: "lx", BusierAccount: "Own", Status: "rejected", DeletedAt: &end}}
|
||||
pos := []activationPO{{ID: 1, PromoteID: &channel, PlatformName: "lx", PromoteAccount: "渠道一", BusierAccount: "Own", BindAt: &start, ReachAt: &end, UnbindAt: &end, Status: "reached", AccumAmount: &amount}, {ID: 2, PlatformName: "hw", BusierAccount: "own", Status: "pending"}, {ID: 3, PlatformName: "lx", BusierAccount: "Other", Status: "in_progress"}, {ID: 4, PlatformName: "lx", BusierAccount: "Own", Status: "rejected", DeletedAt: &end}}
|
||||
if err := oa.Create(&pos).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func prepareActivations(t *testing.T) (*gorm.DB, *gorm.DB, business.ActivationRe
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return db, oa, NewActivationRepo(testData{db}, activationDatabases{oa})
|
||||
return db, oa, NewActivationRepo(testData{db}, activationSDKFixtures(t, db, oa))
|
||||
}
|
||||
func TestActivationListsReadOAAndRestrictAccounts(t *testing.T) {
|
||||
db, _, repo := prepareActivations(t)
|
||||
@@ -67,7 +67,7 @@ func TestActivationListsReadOAAndRestrictAccounts(t *testing.T) {
|
||||
t.Fatalf("OA 列表或排序错误: %+v %v", page, err)
|
||||
}
|
||||
page, err = repo.List(ctx, business.ActivationScope{All: true}, business.ActivationFilter{PlatformNames: []string{"lx"}, Statuses: []string{"reached"}})
|
||||
if err != nil || page.Total != 1 || page.Rows[0].AccumAmount != "30.30" {
|
||||
if err != nil || page.Total != 1 || page.Rows[0].AccumAmount != "33.60" {
|
||||
t.Fatalf("平台或状态筛选错误: %+v %v", page, err)
|
||||
}
|
||||
if _, err := repo.Find(ctx, 4); err != business.ErrActivationNotFound {
|
||||
@@ -87,7 +87,11 @@ func TestActivationListsReadOAAndRestrictAccounts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
func TestActivationSpendTreePlatformAndInclusiveBounds(t *testing.T) {
|
||||
db, _, repo := prepareActivations(t)
|
||||
_, _, repo := prepareActivations(t)
|
||||
db, err := repo.(*activationRepo).databases.Database("lx")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
end := int64(2000)
|
||||
query := business.ActivationSpendQuery{PlatformName: "lx", PromoteID: 100, Start: 1000, End: &end}
|
||||
items, err := repo.Spend(context.Background(), query)
|
||||
@@ -113,3 +117,38 @@ func TestActivationSpendTreePlatformAndInclusiveBounds(t *testing.T) {
|
||||
t.Fatal("确定没有渠道时应返回真实空流水")
|
||||
}
|
||||
}
|
||||
|
||||
func activationSDKFixtures(t *testing.T, db, oa *gorm.DB) activationTestSources {
|
||||
t.Helper()
|
||||
sources := activationTestSources{"oa": oa}
|
||||
var files []struct{ Name, File string }
|
||||
if err := db.Raw("PRAGMA database_list").Scan(&files).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var file string
|
||||
for _, f := range files {
|
||||
if f.Name == "main" {
|
||||
file = f.File
|
||||
}
|
||||
}
|
||||
for _, source := range []string{"lx", "hw", "yq"} {
|
||||
sdk := openTestDB(t)
|
||||
pool, _ := sdk.DB()
|
||||
pool.SetMaxOpenConns(1)
|
||||
if err := sdk.Exec("ATTACH DATABASE ? AS sample", file).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, table := range []string{"tab_promote", "tab_spend", "tab_user"} {
|
||||
if db.Migrator().HasTable(table) {
|
||||
if err := sdk.Exec("CREATE TABLE "+table+" AS SELECT * FROM sample."+table+" WHERE source_db=?", source).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := sdk.Exec("DETACH DATABASE sample").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sources[source] = sdk
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
@@ -55,10 +55,13 @@ func toBizAlertExemption(p alertExemptionPO) *business.AlertExemption {
|
||||
return &business.AlertExemption{ID: p.ID, ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID}, ChannelName: p.ChannelName, OwnerID: p.OwnerID, AddedBy: p.AddedBy, AddedName: p.AddedName, Reason: p.Reason, AddedAt: p.AddedAt, RemovedBy: p.RemovedBy, RemovedName: p.RemovedName, RemoveReason: p.RemoveReason, RemovedAt: p.RemovedAt}
|
||||
}
|
||||
|
||||
type alertGovernanceRepo struct{ data Provider }
|
||||
type alertGovernanceRepo struct {
|
||||
data Provider
|
||||
databases PlatformProvider
|
||||
}
|
||||
|
||||
func NewAlertGovernanceRepo(data Provider) business.AlertGovernanceRepo {
|
||||
return &alertGovernanceRepo{data}
|
||||
func NewAlertGovernanceRepo(data Provider, databases PlatformProvider) business.AlertGovernanceRepo {
|
||||
return &alertGovernanceRepo{data: data, databases: databases}
|
||||
}
|
||||
|
||||
func (r *alertGovernanceRepo) Candidates(ctx context.Context, scope business.UserScope, keyword string, limit int) ([]*business.AlertChannelOption, error) {
|
||||
@@ -196,47 +199,46 @@ func alertLostQuery(db *gorm.DB, sourceColumn, idColumn string, window business.
|
||||
return db.Table("tab_promote lp").Select("1").Where("lp.source_db="+sourceColumn+" AND lp.id="+idColumn).
|
||||
Where("(COALESCE(NULLIF(lp.last_login_time,0),lp.create_time)>0 AND COALESCE(NULLIF(lp.last_login_time,0),lp.create_time)<?) OR (?) < CAST(? AS DECIMAL(20,2))", window.LoginBefore, pay, window.MinPay)
|
||||
}
|
||||
func (r *alertGovernanceRepo) LostExemptions(ctx context.Context, window business.AlertLossWindow) ([]*business.AlertExemption, error) {
|
||||
|
||||
// 清理依据 OA 实际入池记录;不能仅因本地流水低而绕过 OA 的豁免和跨平台判定。
|
||||
func (r *alertGovernanceRepo) LostExemptions(ctx context.Context, _ business.AlertLossWindow) ([]*business.AlertExemption, error) {
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
var pos []alertExemptionPO
|
||||
// 没有在名单渠道时无需读取平台明细。
|
||||
if err := db.Select("id").Where("removed_at IS NULL").Limit(1).Find(&pos).Error; err != nil {
|
||||
var entries []alertExemptionPO
|
||||
if err := db.Where("removed_at IS NULL").Find(&entries).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
if len(pos) == 0 {
|
||||
if len(entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
available, err := businessTablesAvailable(db, "tab_promote", "tab_spend")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
oa, err := r.databases.Database("oa")
|
||||
if err != nil || oa == nil {
|
||||
return nil, business.ErrActivationSource
|
||||
}
|
||||
if !available {
|
||||
return nil, business.ErrAlertChannelsUnavailable
|
||||
oa = oa.WithContext(ctx)
|
||||
ok, err := businessTableAvailable(oa, "sys_platform_promote_active_pool")
|
||||
if err != nil || !ok {
|
||||
return nil, business.ErrActivationSource
|
||||
}
|
||||
result := []*business.AlertExemption{}
|
||||
var cursor uint64
|
||||
for {
|
||||
var batch []alertExemptionPO
|
||||
if err := db.Where("removed_at IS NULL AND id>?", cursor).Order("id").Limit(500).Find(&batch).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
for start := 0; start < len(entries); start += 500 {
|
||||
batch := entries[start:min(start+500, len(entries))]
|
||||
pairs := [][]any{}
|
||||
for _, e := range batch {
|
||||
pairs = append(pairs, []any{e.SourceDB, e.PromoteID})
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
var pools []activationPoolPO
|
||||
if err := oa.Where("(platform_name,promote_id) IN ?", pairs).Find(&pools).Error; err != nil {
|
||||
return nil, business.ErrActivationSource
|
||||
}
|
||||
keys := make([]business.ChannelKey, 0, len(batch))
|
||||
for _, p := range batch {
|
||||
keys = append(keys, business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID})
|
||||
byKey := map[business.ChannelKey]activationPoolPO{}
|
||||
for _, p := range pools {
|
||||
byKey[business.ChannelKey{SourceDB: p.PlatformName, ID: p.PromoteID}] = p
|
||||
}
|
||||
states, err := loadAlertLoss(db, keys, window)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range batch {
|
||||
if lost := states[business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID}]; lost != nil && *lost {
|
||||
result = append(result, toBizAlertExemption(p))
|
||||
for _, e := range batch {
|
||||
if p, ok := byKey[business.ChannelKey{SourceDB: e.SourceDB, ID: e.PromoteID}]; ok && (p.DeletedAt == nil || !p.CreatedAt.Before(e.AddedAt)) {
|
||||
result = append(result, toBizAlertExemption(e))
|
||||
}
|
||||
}
|
||||
cursor = batch[len(batch)-1].ID
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func exerciseAlertOwnerRecheck(t *testing.T, db *gorm.DB) {
|
||||
}
|
||||
write(true)
|
||||
oldExemption := &business.AlertExemption{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 1}, OwnerID: 7, AddedBy: 7, AddedName: "原商务", Reason: "原商务的豁免", AddedAt: now}
|
||||
if err := NewAlertGovernanceRepo(testData{db}).AddExemption(ctx, scope, oldExemption.ChannelKey, func(*business.AlertExemptionTarget) (*business.AlertExemption, error) { return oldExemption, nil }); err != nil {
|
||||
if err := NewAlertGovernanceRepo(testData{db}, activationDatabases{db}).AddExemption(ctx, scope, oldExemption.ChannelKey, func(*business.AlertExemptionTarget) (*business.AlertExemption, error) { return oldExemption, nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func exerciseAlertOwnerRecheck(t *testing.T, db *gorm.DB) {
|
||||
if len(histories) != 2 || histories[0].BusinessID != 7 || histories[0].AlertContent != "换绑前" || histories[1].BusinessID != 8 {
|
||||
t.Fatalf("旧商务预警不应被转移给新商务: %+v", histories)
|
||||
}
|
||||
governance := NewAlertGovernanceRepo(testData{db})
|
||||
governance := NewAlertGovernanceRepo(testData{db}, activationDatabases{db})
|
||||
called := false
|
||||
build := func(*business.AlertExemptionTarget) (*business.AlertExemption, error) {
|
||||
called = true
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestAlertV2ExemptionHistoryAndAutomaticRemoval(t *testing.T) {
|
||||
if err := db.Exec("INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(7,'lx',7,'0')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := NewAlertGovernanceRepo(testData{db})
|
||||
r := NewAlertGovernanceRepo(testData{db}, activationDatabases{db})
|
||||
ctx := context.Background()
|
||||
entry := &business.AlertExemption{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 1}, ChannelName: "父渠道", OwnerID: 7, AddedBy: 7, AddedName: "一级商务", AddedAt: now, Reason: "长期合作"}
|
||||
if err := r.AddExemption(ctx, business.UserScope{All: true}, entry.ChannelKey, func(*business.AlertExemptionTarget) (*business.AlertExemption, error) { return entry, nil }); err != nil {
|
||||
@@ -118,7 +118,16 @@ func TestAlertV2ExemptionHistoryAndAutomaticRemoval(t *testing.T) {
|
||||
if err != nil || written {
|
||||
t.Fatalf("免预警仍生成: %v %v", written, err)
|
||||
}
|
||||
// 父渠道自身近 30 天流水仅 100 元,满足激活池条件;子渠道流水不冒充它自身的流水。
|
||||
// 本地达到流失阈值不等于 OA 已入池;只有收到 OA 入池记录才移出豁免。
|
||||
if err := db.AutoMigrate(&activationPoolPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows, err := r.LostExemptions(ctx, business.AlertLossAt(now)); err != nil || len(rows) != 0 {
|
||||
t.Fatalf("未入池误清理: %v %v", rows, err)
|
||||
}
|
||||
if err := db.Create(&activationPoolPO{ID: 1, PromoteID: 1, PlatformName: "lx", CreatedAt: now}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uc := business.NewAlertGovernanceUsecase(r, nil)
|
||||
if err := uc.Reconcile(ctx, now); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -7,9 +7,14 @@ import (
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
type dashboardRepo struct{ data Provider }
|
||||
type dashboardRepo struct {
|
||||
data Provider
|
||||
databases PlatformProvider
|
||||
}
|
||||
|
||||
func NewDashboardRepo(data Provider) business.DashboardRepo { return &dashboardRepo{data: data} }
|
||||
func NewDashboardRepo(data Provider, databases PlatformProvider) business.DashboardRepo {
|
||||
return &dashboardRepo{data: data, databases: databases}
|
||||
}
|
||||
func (r *dashboardRepo) Overview(ctx context.Context, scope business.AnalyticsScope, sources []string, w business.DashboardWindow) (*business.DashboardData, error) {
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
out := &business.DashboardData{Current: business.DashboardMetrics{Pay: "0.00"}, Previous: business.DashboardMetrics{Pay: "0.00"}, Available: true}
|
||||
@@ -90,5 +95,8 @@ func (r *dashboardRepo) Overview(ctx context.Context, scope business.AnalyticsSc
|
||||
out.Current.NewChannels, out.Previous.NewChannels = channels.CurrentNew, channels.PreviousNew
|
||||
out.Current.Effective, out.Previous.Effective = channels.CurrentEffective, channels.PreviousEffective
|
||||
out.Current.Pay, out.Previous.Pay = amounts.CurrentPay, amounts.PreviousPay
|
||||
if err := r.events(ctx, scope, sources, w, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
func (r *dashboardRepo) events(ctx context.Context, scope business.AnalyticsScope, sources []string, w business.DashboardWindow, out *business.DashboardData) error {
|
||||
out.EventsAvailable = true
|
||||
for _, source := range eventSources(sources) {
|
||||
db, err := r.databases.Database(source)
|
||||
if err != nil || db == nil {
|
||||
out.EventsAvailable = false
|
||||
continue
|
||||
}
|
||||
db = db.WithContext(ctx)
|
||||
ok, err := businessTableAvailable(db, "tab_promote_business_event")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
out.EventsAvailable = false
|
||||
continue
|
||||
}
|
||||
var coverage []channelEventPO
|
||||
if err := db.Where("kind='coverage'").Order("occurred_at,id").Limit(1).Find(&coverage).Error; err != nil {
|
||||
return business.ErrActivationSource
|
||||
}
|
||||
if len(coverage) > 0 && w.PreviousBegin.Before(coverage[0].OccurredAt) {
|
||||
out.EventsAvailable = false
|
||||
out.EventsIncomplete = true
|
||||
}
|
||||
q := db.Model(&channelEventPO{}).Where("kind IN ? AND occurred_at>=? AND occurred_at<?", []string{"locked", "terminated"}, w.PreviousBegin, w.End.AddDate(0, 0, 1))
|
||||
if !scope.Viewer.All {
|
||||
var ids []uint64
|
||||
if err := channelBindingQuery(r.data.DB().WithContext(ctx), scope.Viewer, business.ChannelFilter{Sources: []string{source}}).Distinct("b.platform_busier_id").Pluck("b.platform_busier_id", &ids).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
q = q.Where("old_busier_id IN ?", ids)
|
||||
}
|
||||
if !scope.IncludeSub {
|
||||
q = q.Where("promote_level=1")
|
||||
}
|
||||
var rows []struct {
|
||||
Kind string
|
||||
Current bool
|
||||
Count int64
|
||||
}
|
||||
if err := q.Select("kind,(occurred_at>=?) AS current,COUNT(DISTINCT promote_id) AS count", w.Begin).Group("kind,current").Scan(&rows).Error; err != nil {
|
||||
return business.ErrActivationSource
|
||||
}
|
||||
for _, row := range rows {
|
||||
target := &out.Previous
|
||||
if row.Current {
|
||||
target = &out.Current
|
||||
}
|
||||
if row.Kind == "locked" {
|
||||
target.LockedChannels += row.Count
|
||||
} else {
|
||||
target.TerminatedChannels += row.Count
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -167,7 +167,7 @@ func TestPerformanceOwnerAggregationAndDrilldownUseSameScope(t *testing.T) {
|
||||
if err != nil || count != 2 || len(rows) != 1 || rows[0].ContributorID != 9 {
|
||||
t.Fatal("contribution pagination must preserve total and stable row identity")
|
||||
}
|
||||
data, err := NewDashboardRepo(testData{db}).Overview(context.Background(), scope, []string{"lx"}, business.DashboardWindow{Begin: begin, End: end, PreviousBegin: begin.AddDate(0, 0, -3), PreviousEnd: begin.AddDate(0, 0, -1)})
|
||||
data, err := NewDashboardRepo(testData{db}, activationDatabases{}).Overview(context.Background(), scope, []string{"lx"}, business.DashboardWindow{Begin: begin, End: end, PreviousBegin: begin.AddDate(0, 0, -3), PreviousEnd: begin.AddDate(0, 0, -1)})
|
||||
if err != nil || data.Current.Effective != 1 || data.Current.ActiveChannels != 4 || portraitTestMoney(data.Current.Pay) != "1059.60" {
|
||||
t.Fatalf("首页按区间统计与绩效口径不一致: %+v %v", data, err)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@ import (
|
||||
)
|
||||
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{swmigration.StateMigration(), {ID: "202611060023_business_binding_status_index", Migrate: migrateBindingStatusIndex}, {ID: "202611060022_business_promote_id_capacity", Migrate: migratePromoteIDCapacity}, {ID: "202611050020_business_alert_v2", Migrate: migrateAlertV2}, {ID: "202611040017_business_groups_goals_effectiveness", Migrate: migrateBusinessManagement}, {ID: "202611040018_business_effectiveness_query_index", Migrate: migrateEffectivenessQueryIndex}, {ID: "202611040015_business_approved_game_index", Migrate: migrateApprovedGameIndexes}, {ID: "202611040014_business_report_build_indexes", Migrate: migrateReportBuildQueryIndexes}, {ID: "202611040013_business_query_indexes", Migrate: migrateBusinessQueryIndexes}, {ID: "202611040012_business_server_report_index", Migrate: migrateServerReportIndex}, {ID: "202611040011_business_registration_index", Migrate: migrateRegistrationIndex}, {ID: "202611040010_business_alert_trigger_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &alertTriggerLockPO{}) }}, {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.Step{swmigration.StateMigration(), {ID: "202611060024_business_oa_remark", Migrate: func(db *gorm.DB) error {
|
||||
if db.Migrator().HasTable(&accountPO{}) && !db.Migrator().HasColumn(&accountPO{}, "remark") {
|
||||
return db.Migrator().AddColumn(&accountPO{}, "Remark")
|
||||
}
|
||||
return nil
|
||||
}}, {ID: "202611060023_business_binding_status_index", Migrate: migrateBindingStatusIndex}, {ID: "202611060022_business_promote_id_capacity", Migrate: migratePromoteIDCapacity}, {ID: "202611050020_business_alert_v2", Migrate: migrateAlertV2}, {ID: "202611040017_business_groups_goals_effectiveness", Migrate: migrateBusinessManagement}, {ID: "202611040018_business_effectiveness_query_index", Migrate: migrateEffectivenessQueryIndex}, {ID: "202611040015_business_approved_game_index", Migrate: migrateApprovedGameIndexes}, {ID: "202611040014_business_report_build_indexes", Migrate: migrateReportBuildQueryIndexes}, {ID: "202611040013_business_query_indexes", Migrate: migrateBusinessQueryIndexes}, {ID: "202611040012_business_server_report_index", Migrate: migrateServerReportIndex}, {ID: "202611040011_business_registration_index", Migrate: migrateRegistrationIndex}, {ID: "202611040010_business_alert_trigger_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &alertTriggerLockPO{}) }}, {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{})
|
||||
|
||||
@@ -44,6 +44,7 @@ var ProviderSet = wire.NewSet(
|
||||
databusiness.NewAlertRuleRepo,
|
||||
databusiness.NewAlertRecordRepo,
|
||||
databusiness.NewActivationRepo,
|
||||
databusiness.NewActivationPoolRepo,
|
||||
databusiness.NewChannelRepo,
|
||||
databusiness.NewChannelWriteRepo,
|
||||
databusiness.NewBusinessPortraitRepo,
|
||||
|
||||
Reference in New Issue
Block a user