feat: 迁移渠道激活列表和绑定期间流水

This commit is contained in:
Yvan
2026-11-03 20:53:25 +08:00
parent 838c2c496f
commit 8908430a89
28 changed files with 1180 additions and 9 deletions
+160
View File
@@ -0,0 +1,160 @@
package business
import (
"context"
"errors"
"strings"
"time"
"gorm.io/gorm"
"kra/internal/biz/business"
)
// OA 表只读,不由新商务的系统迁移创建或改写。
type activationPO struct {
ID uint64 `gorm:"primaryKey"`
PromoteID *uint64
PlatformName, PromoteAccount, BusierAccount, ApplicantOA, Status string
AccumAmount *string
BindAt, UnbindAt, ReachAt, DeletedAt *time.Time
}
func (activationPO) TableName() string { return "sys_promote_active_pool_bind" }
func toBizActivation(po activationPO) *business.Activation {
amount := "0.00"
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}
}
type activationRepo struct {
data Provider
databases PlatformProvider
}
func NewActivationRepo(data Provider, databases PlatformProvider) business.ActivationRepo {
return &activationRepo{data: data, databases: databases}
}
func (r *activationRepo) OwnAccounts(ctx context.Context, id uint) ([]string, error) {
accounts := []string{}
err := r.data.DB().WithContext(ctx).Table("sys_user_bind_platform").Where("user_id = ? AND status = ? AND platform_busier_account IS NOT NULL", id, "0").Distinct("platform_busier_account").Pluck("platform_busier_account", &accounts).Error
return accounts, storageError(err)
}
func (r *activationRepo) 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)
available, err := businessTableAvailable(db, "sys_promote_active_pool_bind")
if err != nil || !available {
return nil, business.ErrActivationSource
}
return db, nil
}
func (r *activationRepo) List(ctx context.Context, scope business.ActivationScope, filter business.ActivationFilter, options ...business.ListOption) (*business.ActivationPage, error) {
page := &business.ActivationPage{Rows: []*business.Activation{}, Available: true}
if !scope.All && len(scope.Accounts) == 0 {
return page, nil
}
db, err := r.oa(ctx)
if err != nil {
page.Available = false
return page, nil
}
q := db.Model(&activationPO{}).Where("deleted_at IS NULL")
if !scope.All {
accounts := make([]string, 0, len(scope.Accounts))
for _, account := range scope.Accounts {
accounts = append(accounts, strings.ToLower(account))
}
q = q.Where("LOWER(busier_account) IN ?", accounts)
}
if len(filter.PlatformNames) > 0 {
q = q.Where("platform_name IN ?", filter.PlatformNames)
}
if len(filter.Statuses) > 0 {
q = q.Where("status IN ?", filter.Statuses)
}
if filter.PromoteAccount != "" {
q = q.Where("promote_account LIKE ?", "%"+filter.PromoteAccount+"%")
}
if filter.BusierAccount != "" {
q = q.Where("busier_account LIKE ?", "%"+filter.BusierAccount+"%")
}
if err := q.Count(&page.Total).Error; err != nil {
return nil, business.ErrActivationSource
}
o := business.NewListOptions(options...)
var pos []activationPO
if err := q.Order("id DESC").Offset(o.Offset).Limit(o.Limit).Find(&pos).Error; err != nil {
return nil, business.ErrActivationSource
}
for _, po := range pos {
page.Rows = append(page.Rows, toBizActivation(po))
}
return page, nil
}
func (r *activationRepo) Find(ctx context.Context, id uint64) (*business.Activation, error) {
db, err := r.oa(ctx)
if err != nil {
return nil, err
}
var po activationPO
if err := db.Where("id = ? AND deleted_at IS NULL", id).Take(&po).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, business.ErrActivationNotFound
}
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 {
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
}
+115
View File
@@ -0,0 +1,115 @@
package business
import (
"context"
"errors"
"math/big"
"path/filepath"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"kra/internal/biz/business"
)
type activationDatabases struct{ oa *gorm.DB }
func (d activationDatabases) Database(name string) (*gorm.DB, error) {
if name != "oa" || d.oa == nil {
return nil, errors.New("未配置")
}
return d.oa, nil
}
func prepareActivations(t *testing.T) (*gorm.DB, *gorm.DB, business.ActivationRepo) {
t.Helper()
db := prepareAccounts(t)
oa, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "oa.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
pool, _ := oa.DB()
t.Cleanup(func() { _ = pool.Close() })
if err := oa.AutoMigrate(&activationPO{}); err != nil {
t.Fatal(err)
}
start := time.Unix(1000, 0).UTC()
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}}
if err := oa.Create(&pos).Error; err != nil {
t.Fatal(err)
}
for _, sql := range []string{
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(7,'lx',1,'Own','0'),(8,'lx',2,'Child','0'),(7,'hw',3,'Pending','3')",
"ALTER TABLE tab_promote ADD COLUMN top_promote_id INTEGER",
"ALTER TABLE tab_promote ADD COLUMN parent_id INTEGER",
"INSERT INTO tab_promote(id,source_db,top_promote_id,parent_id) VALUES(100,'lx',0,0),(101,'lx',0,100),(101,'lx',0,100),(102,'lx',100,101),(103,'lx',200,200),(100,'hw',0,0)",
"CREATE TABLE tab_spend(source_db TEXT,promote_id INTEGER,pay_status INTEGER,pay_time INTEGER,game_id INTEGER,game_name TEXT,pay_amount DECIMAL(20,2))",
"INSERT INTO tab_spend VALUES('lx',100,1,1000,1,'A',10.10),('lx',101,1,2000,1,'Z',20.20),('lx',102,1,1500,2,'B',3.30),('lx',103,1,1500,1,'X',900),('hw',100,1,1500,1,'X',999),('lx',100,1,999,1,'X',800),('lx',100,1,2001,1,'X',700),('lx',100,0,1500,1,'X',600)",
} {
if err := db.Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
return db, oa, NewActivationRepo(testData{db}, activationDatabases{oa})
}
func TestActivationListsReadOAAndRestrictAccounts(t *testing.T) {
db, _, repo := prepareActivations(t)
ctx := context.Background()
accounts, err := repo.OwnAccounts(ctx, 7)
if err != nil || len(accounts) != 1 || accounts[0] != "Own" {
t.Fatalf("本人已通过账号范围错误: %+v %v", accounts, err)
}
page, err := repo.List(ctx, business.ActivationScope{Accounts: []string{"OWN"}}, business.ActivationFilter{})
if err != nil || page.Total != 2 || page.Rows[0].ID != 2 {
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" {
t.Fatalf("平台或状态筛选错误: %+v %v", page, err)
}
if _, err := repo.Find(ctx, 4); err != business.ErrActivationNotFound {
t.Fatal("软删除 OA 记录仍可访问")
}
// 即使主库有同名表,也不能在 OA 未配置时回退到主库。
if err := db.AutoMigrate(&activationPO{}); err != nil {
t.Fatal(err)
}
missing := NewActivationRepo(testData{db}, activationDatabases{})
page, err = missing.List(ctx, business.ActivationScope{All: true}, business.ActivationFilter{})
if err != nil || page.Available {
t.Fatal("OA 缺配置必须明确不可用")
}
if _, err := missing.Find(ctx, 1); err != business.ErrActivationSource {
t.Fatal("OA 缺配置不应读取主库")
}
}
func TestActivationSpendTreePlatformAndInclusiveBounds(t *testing.T) {
db, _, repo := prepareActivations(t)
end := int64(2000)
query := business.ActivationSpendQuery{PlatformName: "lx", PromoteID: 100, Start: 1000, End: &end}
items, err := repo.Spend(context.Background(), query)
amount := func(value string) string {
n, ok := new(big.Rat).SetString(value)
if !ok {
return "无效金额"
}
return n.FloatString(2)
}
if err != nil || len(items) != 2 || items[0].GameName != "Z" || amount(items[0].Amount) != "30.30" || amount(items[1].Amount) != "3.30" {
t.Fatalf("渠道树、同号平台、成功支付、起止边界或重复渠道导致金额错误: %+v %v", items, err)
}
if err := db.Exec("DROP TABLE tab_spend").Error; err != nil {
t.Fatal(err)
}
if _, err := repo.Spend(context.Background(), query); err != business.ErrActivationMetrics {
t.Fatal("缺流水表未报告不可用")
}
query.PromoteID = 404
items, err = repo.Spend(context.Background(), query)
if err != nil || len(items) != 0 {
t.Fatal("确定没有渠道时应返回真实空流水")
}
}
+1
View File
@@ -36,6 +36,7 @@ var ProviderSet = wire.NewSet(
databusiness.NewBusinessPromoteRepo,
databusiness.NewAlertRuleRepo,
databusiness.NewAlertRecordRepo,
databusiness.NewActivationRepo,
NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),