feat: 迁移渠道激活列表和绑定期间流水
This commit is contained in:
+1
-1
@@ -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)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrActivationNotFound = errors.NotFound("SW_ACTIVATION_NOT_FOUND", "绑定记录不存在")
|
||||
ErrActivationScope = errors.Forbidden("SW_ACTIVATION_SCOPE", "无权查看该绑定记录")
|
||||
ErrActivationPending = errors.BadRequest("SW_ACTIVATION_PENDING", "审核通过后可查看")
|
||||
ErrActivationSource = errors.BadRequest("SW_ACTIVATION_SOURCE", "OA 渠道激活数据源未接入或暂不可用")
|
||||
ErrActivationMetrics = errors.BadRequest("SW_ACTIVATION_METRICS", "渠道或流水数据尚未接入,暂时无法查询流水明细")
|
||||
ErrActivationInput = errors.BadRequest("SW_ACTIVATION_INPUT", "渠道激活查询参数不正确")
|
||||
)
|
||||
|
||||
type Activation struct {
|
||||
ID uint64
|
||||
PromoteID *uint64
|
||||
PlatformName, PromoteAccount, BusierAccount, ApplicantOA, Status, AccumAmount string
|
||||
BindAt, UnbindAt, ReachAt *time.Time
|
||||
}
|
||||
|
||||
func (a *Activation) EndAt() *time.Time {
|
||||
if a.Status == "reached" && a.ReachAt != nil {
|
||||
return a.ReachAt
|
||||
}
|
||||
return a.UnbindAt
|
||||
}
|
||||
|
||||
type ActivationScope struct {
|
||||
All bool
|
||||
Accounts []string
|
||||
}
|
||||
type ActivationFilter struct {
|
||||
PlatformNames, Statuses []string
|
||||
PromoteAccount, BusierAccount string
|
||||
}
|
||||
type ActivationPage struct {
|
||||
Rows []*Activation
|
||||
Total int64
|
||||
Available bool
|
||||
}
|
||||
type ActivationSpendQuery struct {
|
||||
PlatformName string
|
||||
PromoteID uint64
|
||||
Start int64
|
||||
End *int64
|
||||
}
|
||||
type ActivationSpendItem struct{ GameName, Amount string }
|
||||
type ActivationSpendDetail struct {
|
||||
Activation *Activation
|
||||
Items []*ActivationSpendItem
|
||||
TotalAmount string
|
||||
}
|
||||
|
||||
type ActivationRepo interface {
|
||||
OwnAccounts(context.Context, uint) ([]string, error)
|
||||
List(context.Context, ActivationScope, ActivationFilter, ...ListOption) (*ActivationPage, error)
|
||||
Find(context.Context, uint64) (*Activation, error)
|
||||
Spend(context.Context, ActivationSpendQuery) ([]*ActivationSpendItem, error)
|
||||
}
|
||||
|
||||
type ActivationUsecase struct {
|
||||
repo ActivationRepo
|
||||
accounts *AccountUsecase
|
||||
}
|
||||
|
||||
func NewActivationUsecase(repo ActivationRepo, accounts *AccountUsecase) *ActivationUsecase {
|
||||
return &ActivationUsecase{repo: repo, accounts: accounts}
|
||||
}
|
||||
|
||||
func (uc *ActivationUsecase) scope(ctx context.Context) (ActivationScope, error) {
|
||||
actor, _, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return ActivationScope{}, err
|
||||
}
|
||||
if actor.AuthorityID == 888 {
|
||||
return ActivationScope{All: true}, nil
|
||||
}
|
||||
accounts, err := uc.repo.OwnAccounts(ctx, actor.UserID)
|
||||
if err != nil {
|
||||
return ActivationScope{}, err
|
||||
}
|
||||
scope := ActivationScope{}
|
||||
seen := map[string]bool{}
|
||||
for _, account := range accounts {
|
||||
account = strings.TrimSpace(account)
|
||||
key := strings.ToLower(account)
|
||||
if key != "" && !seen[key] {
|
||||
scope.Accounts = append(scope.Accounts, account)
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
func (uc *ActivationUsecase) List(ctx context.Context, filter ActivationFilter, options ...ListOption) (*ActivationPage, error) {
|
||||
scope, err := uc.scope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !scope.All && filter.BusierAccount != "" {
|
||||
matched := []string{}
|
||||
keyword := strings.ToLower(strings.TrimSpace(filter.BusierAccount))
|
||||
for _, account := range scope.Accounts {
|
||||
if strings.Contains(strings.ToLower(account), keyword) {
|
||||
matched = append(matched, account)
|
||||
}
|
||||
}
|
||||
scope.Accounts = matched
|
||||
filter.BusierAccount = ""
|
||||
}
|
||||
if !scope.All && len(scope.Accounts) == 0 {
|
||||
return &ActivationPage{Rows: []*Activation{}, Available: true}, nil
|
||||
}
|
||||
for i, status := range filter.Statuses {
|
||||
filter.Statuses[i] = NormalizeActivationStatus(status)
|
||||
}
|
||||
return uc.repo.List(ctx, scope, filter, options...)
|
||||
}
|
||||
|
||||
func (uc *ActivationUsecase) SpendDetail(ctx context.Context, id uint64) (*ActivationSpendDetail, error) {
|
||||
scope, err := uc.scope(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !scope.All && len(scope.Accounts) == 0 {
|
||||
return nil, ErrActivationScope
|
||||
}
|
||||
activation, err := uc.repo.Find(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !scope.All {
|
||||
found := false
|
||||
for _, account := range scope.Accounts {
|
||||
if strings.EqualFold(account, strings.TrimSpace(activation.BusierAccount)) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, ErrActivationScope
|
||||
}
|
||||
}
|
||||
if activation.BindAt == nil {
|
||||
return nil, ErrActivationPending
|
||||
}
|
||||
detail := &ActivationSpendDetail{Activation: activation, Items: []*ActivationSpendItem{}, TotalAmount: "0.00"}
|
||||
if strings.TrimSpace(activation.PlatformName) == "" || activation.PromoteID == nil || *activation.PromoteID == 0 {
|
||||
return detail, nil
|
||||
}
|
||||
query := ActivationSpendQuery{PlatformName: activation.PlatformName, PromoteID: *activation.PromoteID, Start: activation.BindAt.Unix()}
|
||||
if end := activation.EndAt(); end != nil {
|
||||
n := end.Unix()
|
||||
query.End = &n
|
||||
}
|
||||
detail.Items, err = uc.repo.Spend(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 金额合计不用浮点数,避免多个游戏相加产生误差或大金额精度损失。
|
||||
total := new(big.Rat)
|
||||
for _, item := range detail.Items {
|
||||
amount, ok := new(big.Rat).SetString(item.Amount)
|
||||
if !ok {
|
||||
return nil, ErrStorage
|
||||
}
|
||||
item.Amount = amount.FloatString(2)
|
||||
total.Add(total, amount)
|
||||
}
|
||||
detail.TotalAmount = total.FloatString(2)
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func NormalizeActivationStatus(status string) string {
|
||||
status = strings.TrimSpace(status)
|
||||
if code, ok := map[string]string{"待审核": "pending", "进行中": "in_progress", "已达标": "reached", "已驳回": "rejected", "已解绑": "unbound"}[status]; ok {
|
||||
return code
|
||||
}
|
||||
return status
|
||||
}
|
||||
func ActivationStatusLabel(status string) string {
|
||||
if label, ok := map[string]string{"pending": "待审核", "in_progress": "进行中", "reached": "已达标", "rejected": "已驳回", "unbound": "已解绑"}[status]; ok {
|
||||
return label
|
||||
}
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type activationRepoFake struct {
|
||||
ActivationRepo
|
||||
accounts []string
|
||||
requested uint
|
||||
scope ActivationScope
|
||||
filter ActivationFilter
|
||||
record *Activation
|
||||
query ActivationSpendQuery
|
||||
items []*ActivationSpendItem
|
||||
listCalls int
|
||||
}
|
||||
|
||||
func (r *activationRepoFake) OwnAccounts(_ context.Context, id uint) ([]string, error) {
|
||||
r.requested = id
|
||||
return r.accounts, nil
|
||||
}
|
||||
func (r *activationRepoFake) List(_ context.Context, scope ActivationScope, filter ActivationFilter, _ ...ListOption) (*ActivationPage, error) {
|
||||
r.listCalls++
|
||||
r.scope = scope
|
||||
r.filter = filter
|
||||
return &ActivationPage{Rows: []*Activation{}, Available: true}, nil
|
||||
}
|
||||
func (r *activationRepoFake) Find(context.Context, uint64) (*Activation, error) { return r.record, nil }
|
||||
func (r *activationRepoFake) Spend(_ context.Context, q ActivationSpendQuery) ([]*ActivationSpendItem, error) {
|
||||
r.query = q
|
||||
return r.items, nil
|
||||
}
|
||||
func activationFixture() (*ActivationUsecase, *activationRepoFake) {
|
||||
a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1, Level: 0}, 7: {ID: 7, Level: 1}, 8: {ID: 8, Level: 2, IDPath: "0,7"}}}
|
||||
r := &activationRepoFake{accounts: []string{" Own ", "own", "Other", ""}}
|
||||
return NewActivationUsecase(r, NewAccountUsecase(a)), r
|
||||
}
|
||||
func TestActivationScopeIncludesOwnAccountsOnly(t *testing.T) {
|
||||
uc, r := activationFixture()
|
||||
if _, err := uc.List(accountContext(8, 902), ActivationFilter{BusierAccount: "oWn", Statuses: []string{"已达标"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.requested != 8 || r.scope.All || len(r.scope.Accounts) != 1 || r.scope.Accounts[0] != "Own" || r.filter.BusierAccount != "" || r.filter.Statuses[0] != "reached" {
|
||||
t.Fatalf("本人账号或中文状态筛选错误: %+v", r)
|
||||
}
|
||||
page, err := uc.List(accountContext(7, 901), ActivationFilter{BusierAccount: "下级账号"})
|
||||
if err != nil || page.Total != 0 || r.listCalls != 1 {
|
||||
t.Fatal("无匹配绑定应直接返回空集")
|
||||
}
|
||||
if _, err := uc.List(accountContext(1, 888), ActivationFilter{BusierAccount: "any"}); err != nil || !r.scope.All || r.filter.BusierAccount != "any" {
|
||||
t.Fatal("超管可筛全部 OA 绑定")
|
||||
}
|
||||
}
|
||||
func TestActivationSpendScopeCutoffAndExactMoney(t *testing.T) {
|
||||
uc, r := activationFixture()
|
||||
start := time.Date(2026, 11, 3, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
reach, unbind := start.Add(time.Hour), start.Add(2*time.Hour)
|
||||
id := uint64(100)
|
||||
r.record = &Activation{ID: 10, PromoteID: &id, PlatformName: "lx", BusierAccount: "OWN", BindAt: &start, ReachAt: &reach, UnbindAt: &unbind, Status: "reached"}
|
||||
r.items = []*ActivationSpendItem{{Amount: "9007199254740993.01"}, {Amount: "0.09"}}
|
||||
detail, err := uc.SpendDetail(accountContext(8, 902), 10)
|
||||
if err != nil || detail.TotalAmount != "9007199254740993.10" || r.query.Start != start.Unix() || r.query.End == nil || *r.query.End != reach.Unix() {
|
||||
t.Fatalf("达标截止或精确金额错误: %+v %+v %v", detail, r.query, err)
|
||||
}
|
||||
r.record.Status = "unbound"
|
||||
if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != nil || *r.query.End != unbind.Unix() {
|
||||
t.Fatal("解绑截止错误")
|
||||
}
|
||||
r.record.BusierAccount = "not-owned"
|
||||
if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != ErrActivationScope {
|
||||
t.Fatal("非本人绑定可读取流水")
|
||||
}
|
||||
r.record.BindAt = nil
|
||||
if _, err := uc.SpendDetail(accountContext(1, 888), 10); err != ErrActivationPending {
|
||||
t.Fatal("未审核绑定不应查询流水")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("确定没有渠道时应返回真实空流水")
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
func Definition() module.Definition {
|
||||
return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{
|
||||
Menus: []module.Menu{
|
||||
{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},
|
||||
|
||||
@@ -33,8 +33,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
||||
menus += len(definition.Surface.Menus)
|
||||
apis += len(definition.Surface.APIs)
|
||||
}
|
||||
if menus != 9 || apis != 44 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 9/44", menus, apis)
|
||||
if menus != 10 || apis != 46 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 10/46", menus, apis)
|
||||
}
|
||||
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
||||
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
||||
|
||||
@@ -41,6 +41,8 @@ type routeValue struct {
|
||||
// Gin router files still bind handlers; tests keep those registrations aligned
|
||||
// with this catalog while public, audit and API metadata live only here.
|
||||
var routes = map[string]routeValue{
|
||||
"GET /sw/promoteActivePool/bindList": {group: "渠道激活", description: "查询 OA 渠道激活绑定"},
|
||||
"GET /sw/promoteActivePool/bindSpendDetail": {group: "渠道激活", description: "查询绑定期间游戏流水"},
|
||||
"GET /sw/alertRecord/list": {group: "预警记录", description: "查询预警列表及状态统计"},
|
||||
"GET /sw/alertRecord/detail/:id": {group: "预警记录", description: "查询预警详情及操作记录"},
|
||||
"POST /sw/alertRecord/startProcess": {group: "预警记录", description: "开始处理预警", audit: true},
|
||||
|
||||
@@ -26,6 +26,7 @@ func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlServ
|
||||
|
||||
func emptyHandlers() *handler.Set {
|
||||
return &handler.Set{
|
||||
Activation: &handler.Activation{},
|
||||
AlertRecord: &handler.AlertRecord{},
|
||||
AlertRule: &handler.AlertRule{},
|
||||
BusinessPromote: &handler.BusinessPromote{},
|
||||
@@ -101,7 +102,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
||||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
||||
t.Fatalf("registered route log count = %d, want %d", got, want)
|
||||
}
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":227`) {
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":229`) {
|
||||
t.Fatalf("startup route summary is missing: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
"kra/internal/service/business"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type Activation struct{ service *business.ActivationService }
|
||||
|
||||
func NewActivation(service *business.ActivationService) *Activation {
|
||||
return &Activation{service: service}
|
||||
}
|
||||
func (h *Activation) List(c *gin.Context) {
|
||||
var req dto.ActivationQuery
|
||||
if c.ShouldBindQuery(&req) != nil {
|
||||
Fail(c, "渠道激活查询参数不正确")
|
||||
return
|
||||
}
|
||||
result, err := h.service.List(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
Fail(c, errors.FromError(err).Message)
|
||||
return
|
||||
}
|
||||
OKWithData(c, result)
|
||||
}
|
||||
func (h *Activation) SpendDetail(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Query("id"), 10, 64)
|
||||
if err != nil {
|
||||
Fail(c, "绑定记录不存在")
|
||||
return
|
||||
}
|
||||
result, err := h.service.SpendDetail(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
Fail(c, errors.FromError(err).Message)
|
||||
return
|
||||
}
|
||||
OKWithData(c, result)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewBusinessPromote,
|
||||
NewAlertRule,
|
||||
NewAlertRecord,
|
||||
NewActivation,
|
||||
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
|
||||
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
|
||||
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
type Set struct {
|
||||
Activation *Activation
|
||||
AlertRecord *AlertRecord
|
||||
AlertRule *AlertRule
|
||||
BusinessPromote *BusinessPromote
|
||||
|
||||
@@ -20,6 +20,10 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
||||
return
|
||||
}
|
||||
registrations := []func(){
|
||||
func() {
|
||||
private.GET("/sw/promoteActivePool/bindList", r.handlers.Activation.List)
|
||||
private.GET("/sw/promoteActivePool/bindSpendDetail", r.handlers.Activation.SpendDetail)
|
||||
},
|
||||
func() { RegisterAlertRecord(private, r.handlers.AlertRecord) },
|
||||
func() { RegisterAlertRule(private, r.handlers.AlertRule) },
|
||||
func() { private.GET("/sw/business/:userId/promotes", r.handlers.BusinessPromote.List) },
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"kra/internal/biz/business"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type ActivationUsecase interface {
|
||||
List(context.Context, business.ActivationFilter, ...business.ListOption) (*business.ActivationPage, error)
|
||||
SpendDetail(context.Context, uint64) (*business.ActivationSpendDetail, error)
|
||||
}
|
||||
type ActivationService struct{ uc ActivationUsecase }
|
||||
|
||||
func NewActivationService(uc *business.ActivationUsecase) *ActivationService {
|
||||
return &ActivationService{uc: uc}
|
||||
}
|
||||
|
||||
func splitQueryValues(values []string) []string {
|
||||
result := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" && !seen[part] {
|
||||
result = append(result, part)
|
||||
seen[part] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *ActivationService) List(ctx context.Context, req *dto.ActivationQuery) (*dto.ActivationPage, error) {
|
||||
if req == nil {
|
||||
return nil, business.ErrActivationInput
|
||||
}
|
||||
page, size := req.Page, req.PageSize
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 10
|
||||
}
|
||||
if size > 500 {
|
||||
size = 500
|
||||
}
|
||||
if page > int(^uint(0)>>1)/size {
|
||||
return nil, business.ErrActivationInput
|
||||
}
|
||||
filter := business.ActivationFilter{PlatformNames: splitQueryValues(req.PlatformNames), Statuses: splitQueryValues(req.Statuses), PromoteAccount: strings.TrimSpace(req.PromoteAccount), BusierAccount: strings.TrimSpace(req.BusierAccount)}
|
||||
result, err := s.uc.List(ctx, filter, business.ListOffset((page-1)*size), business.ListLimit(size))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &dto.ActivationPage{Rows: []*dto.ActivationResponse{}, Total: result.Total, Available: result.Available}
|
||||
for _, r := range result.Rows {
|
||||
out.Rows = append(out.Rows, &dto.ActivationResponse{ID: r.ID, PromoteAccount: r.PromoteAccount, PlatformName: r.PlatformName, PlatformTitle: business.PlatformName(r.PlatformName), BusierAccount: r.BusierAccount, ApplicantOA: r.ApplicantOA, BindAt: localTime(r.BindAt), UnbindAt: localTime(r.UnbindAt), ReachAt: localTime(r.ReachAt), EndAt: localTime(r.EndAt()), AccumAmount: r.AccumAmount, Status: r.Status, StatusText: business.ActivationStatusLabel(r.Status)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *ActivationService) SpendDetail(ctx context.Context, id uint64) (*dto.ActivationSpendDetail, error) {
|
||||
if id == 0 {
|
||||
return nil, business.ErrActivationNotFound
|
||||
}
|
||||
result, err := s.uc.SpendDetail(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := result.Activation
|
||||
out := &dto.ActivationSpendDetail{PromoteAccount: a.PromoteAccount, BindAt: localTime(a.BindAt), EndAt: localTime(a.EndAt()), List: []*dto.ActivationSpendItem{}, TotalAmount: result.TotalAmount}
|
||||
for _, item := range result.Items {
|
||||
out.List = append(out.List, &dto.ActivationSpendItem{PlatformTitle: business.PlatformName(a.PlatformName), GameName: item.GameName, TotalAmount: item.Amount})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package dto
|
||||
|
||||
type ActivationQuery struct {
|
||||
Page int `form:"pageNum"`
|
||||
PageSize int `form:"pageSize"`
|
||||
PlatformNames []string `form:"platformNames"`
|
||||
Statuses []string `form:"statuses"`
|
||||
PromoteAccount string `form:"promoteAccount"`
|
||||
BusierAccount string `form:"busierAccount"`
|
||||
}
|
||||
type ActivationResponse struct {
|
||||
ID uint64 `json:"id,string"`
|
||||
PromoteAccount string `json:"promoteAccount"`
|
||||
PlatformName string `json:"platformName"`
|
||||
PlatformTitle string `json:"platformTitle"`
|
||||
BusierAccount string `json:"busierAccount"`
|
||||
ApplicantOA string `json:"applicantOa"`
|
||||
BindAt *string `json:"bindAt"`
|
||||
UnbindAt *string `json:"unbindAt"`
|
||||
ReachAt *string `json:"reachAt"`
|
||||
EndAt *string `json:"endAt"`
|
||||
AccumAmount string `json:"accumAmount"`
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"statusText"`
|
||||
}
|
||||
type ActivationPage struct {
|
||||
Rows []*ActivationResponse `json:"rows"`
|
||||
Total int64 `json:"total"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
type ActivationSpendItem struct {
|
||||
PlatformTitle string `json:"platformTitle"`
|
||||
GameName string `json:"gameName"`
|
||||
TotalAmount string `json:"totalAmount"`
|
||||
}
|
||||
type ActivationSpendDetail struct {
|
||||
PromoteAccount string `json:"promoteAccount"`
|
||||
BindAt *string `json:"bindAt"`
|
||||
EndAt *string `json:"endAt"`
|
||||
List []*ActivationSpendItem `json:"list"`
|
||||
TotalAmount string `json:"totalAmount"`
|
||||
}
|
||||
@@ -19,6 +19,7 @@ var ProviderSet = wire.NewSet(
|
||||
businessservice.NewBusinessPromoteService,
|
||||
businessservice.NewAlertRuleService,
|
||||
businessservice.NewAlertRecordService,
|
||||
businessservice.NewActivationService,
|
||||
systemservice.ProviderSet,
|
||||
paymentservice.ProviderSet,
|
||||
integrationservice.ProviderSet,
|
||||
|
||||
Reference in New Issue
Block a user