feat: integrate OA activation workflows and business migration

This commit is contained in:
Yvan
2026-11-07 00:09:19 +08:00
parent 64aca495f2
commit 9caf5b1abc
47 changed files with 1831 additions and 152 deletions
+1 -1
View File
@@ -11,4 +11,4 @@ import (
)
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewOtherSettingsUsecase, business.NewTeamUsecase, business.NewGoalUsecase, business.NewEffectivenessUsecase, business.NewPerformanceUsecase, business.NewDashboardUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertGovernanceUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase)
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewOtherSettingsUsecase, business.NewTeamUsecase, business.NewGoalUsecase, business.NewEffectivenessUsecase, business.NewPerformanceUsecase, business.NewDashboardUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertGovernanceUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewActivationPoolUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase)
+29 -12
View File
@@ -19,17 +19,26 @@ var (
)
type Activation struct {
BusierID uint64
CreatedAt *time.Time
RegisteredPlayers *int64
Channels []ChannelKey
MetricsAvailable bool
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
// 达标仍处于绑定关系内,流水和注册统计持续到实际解绑。
func (a *Activation) EndAt() *time.Time { return a.UnbindAt }
// 换绑即结束原关系,即使 OA 定时扫描还没执行,也不能读取新归属期间的流水。
func (a *Activation) EndOnTransfer(at time.Time) {
if a.BindAt != nil && !at.Before(*a.BindAt) && (a.UnbindAt == nil || at.Before(*a.UnbindAt)) {
a.UnbindAt = &at
a.Status = "unbound"
}
return a.UnbindAt
}
type ActivationAccount struct {
@@ -41,6 +50,7 @@ type ActivationScope struct {
Accounts []ActivationAccount
}
type ActivationFilter struct {
IDCardDedup bool
PlatformNames, Statuses []string
PromoteAccount, BusierAccount string
}
@@ -50,12 +60,13 @@ type ActivationPage struct {
Available bool
}
type ActivationSpendQuery struct {
Channels []ChannelKey
PlatformName string
PromoteID uint64
Start int64
End *int64
}
type ActivationSpendItem struct{ GameName, Amount string }
type ActivationSpendItem struct{ PlatformName, GameName, Amount string }
type ActivationSpendDetail struct {
Activation *Activation
Items []*ActivationSpendItem
@@ -70,12 +81,13 @@ type ActivationRepo interface {
}
type ActivationUsecase struct {
repo ActivationRepo
accounts *AccountUsecase
repo ActivationRepo
accounts *AccountUsecase
preferences *UserPreferenceUsecase
}
func NewActivationUsecase(repo ActivationRepo, accounts *AccountUsecase) *ActivationUsecase {
return &ActivationUsecase{repo: repo, accounts: accounts}
func NewActivationUsecase(repo ActivationRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *ActivationUsecase {
return &ActivationUsecase{repo: repo, accounts: accounts, preferences: preferences}
}
func (uc *ActivationUsecase) scope(ctx context.Context) (ActivationScope, error) {
@@ -126,6 +138,11 @@ func (uc *ActivationUsecase) List(ctx context.Context, filter ActivationFilter,
for i, status := range filter.Statuses {
filter.Statuses[i] = NormalizeActivationStatus(status)
}
pref, err := uc.preferences.Current(ctx)
if err != nil {
return nil, err
}
filter.IDCardDedup = pref.IDCardDedup
return uc.repo.List(ctx, scope, filter, options...)
}
@@ -160,7 +177,7 @@ func (uc *ActivationUsecase) SpendDetail(ctx context.Context, id uint64) (*Activ
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()}
query := ActivationSpendQuery{Channels: activation.Channels, PlatformName: activation.PlatformName, PromoteID: *activation.PromoteID, Start: activation.BindAt.Unix()}
if end := activation.EndAt(); end != nil {
n := end.Unix()
query.End = &n
@@ -185,13 +202,13 @@ func (uc *ActivationUsecase) SpendDetail(ctx context.Context, id uint64) (*Activ
func NormalizeActivationStatus(status string) string {
status = strings.TrimSpace(status)
if code, ok := map[string]string{"待审核": "pending", "进行中": "in_progress", "已达标": "reached", "已驳回": "rejected", "已解绑": "unbound"}[status]; ok {
if code, ok := map[string]string{"待审核": "pending", "审核中": "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 {
if label, ok := map[string]string{"pending": "审核中", "in_progress": "进行中", "reached": "已达标", "rejected": "已驳回", "unbound": "已解绑"}[status]; ok {
return label
}
return status
+117
View File
@@ -0,0 +1,117 @@
package business
import (
"context"
"strings"
"time"
)
type ActivationPoolFilter struct {
Sources []string
Name, ChannelType, Status, Target string
Begin, End *time.Time
}
type ActivationPoolChannel struct {
ID, PromoteID uint64
SourceDB, Name, ChannelType, Status string
NoLoginDays *int64
EnteredAt time.Time
Rejected bool
RejectReason string
RejectedAt *time.Time
}
type ActivationPoolPage struct {
Rows []*ActivationPoolChannel
Total int64
Available bool
}
type ActivationBindingOption struct {
SourceDB, Account string
ID uint64
Level int
}
type ActivationApplication struct {
UserID uint
PoolID, BusierID uint64
SourceDB string
}
type ActivationChurn struct {
ID, PromoteID uint64
SourceDB, Name, From, To, Reason, Operator string
At time.Time
ToPool bool
}
type ActivationChurnPage struct {
Rows []*ActivationChurn
Total int64
Available bool
}
type ActivationPoolRepo interface {
Pool(context.Context, ActivationPoolFilter, ...ListOption) (*ActivationPoolPage, error)
PoolChannel(context.Context, uint64) (*ActivationPoolChannel, error)
BindingOptions(context.Context, uint) ([]*ActivationBindingOption, error)
Churn(context.Context, ActivationPoolFilter, ...ListOption) (*ActivationChurnPage, error)
}
type ActivationGateway interface {
Apply(context.Context, ActivationApplication) error
}
type ActivationPoolUsecase struct {
repo ActivationPoolRepo
gateway ActivationGateway
accounts *AccountUsecase
}
func NewActivationPoolUsecase(repo ActivationPoolRepo, gateway ActivationGateway, accounts *AccountUsecase) *ActivationPoolUsecase {
return &ActivationPoolUsecase{repo: repo, gateway: gateway, accounts: accounts}
}
func (uc *ActivationPoolUsecase) Pool(ctx context.Context, f ActivationPoolFilter, o ...ListOption) (*ActivationPoolPage, error) {
if _, _, err := uc.accounts.actor(ctx); err != nil {
return nil, err
}
return uc.repo.Pool(ctx, f, o...)
}
func (uc *ActivationPoolUsecase) Options(ctx context.Context) ([]*ActivationBindingOption, error) {
a, _, err := uc.accounts.actor(ctx)
if err != nil {
return nil, err
}
return uc.repo.BindingOptions(ctx, a.UserID)
}
func (uc *ActivationPoolUsecase) Apply(ctx context.Context, poolID, busierID uint64, source string) error {
a, _, err := uc.accounts.actor(ctx)
if err != nil {
return err
}
source, err = NormalizePlatform(source)
if err != nil || poolID == 0 || busierID == 0 {
return ErrActivationInput
}
options, err := uc.repo.BindingOptions(ctx, a.UserID)
if err != nil {
return err
}
owned := false
for _, o := range options {
if o.SourceDB == source && o.ID == busierID {
owned = true
break
}
}
if !owned {
return ErrActivationScope
}
channel, err := uc.repo.PoolChannel(ctx, poolID)
if err != nil {
return err
}
if !strings.EqualFold(channel.SourceDB, source) {
return ErrActivationInput
}
return uc.gateway.Apply(ctx, ActivationApplication{UserID: a.UserID, PoolID: poolID, BusierID: busierID, SourceDB: source})
}
func (uc *ActivationPoolUsecase) Churn(ctx context.Context, f ActivationPoolFilter, o ...ListOption) (*ActivationChurnPage, error) {
if _, _, err := uc.accounts.actor(ctx); err != nil {
return nil, err
}
return uc.repo.Churn(ctx, f, o...)
}
@@ -0,0 +1,56 @@
package business
import (
"context"
"testing"
)
type activationPoolFake struct {
ActivationPoolRepo
options []*ActivationBindingOption
requested uint
}
func (r *activationPoolFake) BindingOptions(_ context.Context, id uint) ([]*ActivationBindingOption, error) {
r.requested = id
return r.options, nil
}
func (r *activationPoolFake) PoolChannel(context.Context, uint64) (*ActivationPoolChannel, error) {
return &ActivationPoolChannel{ID: 10, SourceDB: "lx"}, nil
}
type activationGatewayFake struct {
calls int
request ActivationApplication
}
func (g *activationGatewayFake) Apply(_ context.Context, a ActivationApplication) error {
g.calls++
g.request = a
return nil
}
func TestActivationApplicationUsesAuthenticatedIdentityAndOwnedBinding(t *testing.T) {
accountUC, _ := activationFixture()
repo := &activationPoolFake{options: []*ActivationBindingOption{{ID: 9, SourceDB: "lx", Account: "own"}}}
gateway := &activationGatewayFake{}
uc := NewActivationPoolUsecase(repo, gateway, accountUC.accounts)
ctx := accountContext(8, 902)
if err := uc.Apply(ctx, 10, 9, "lx"); err != nil {
t.Fatal(err)
}
if gateway.request.UserID != 8 || repo.requested != 8 || gateway.request.BusierID != 9 {
t.Fatal("applicant came from request rather than session")
}
for _, a := range []ActivationApplication{{PoolID: 10, BusierID: 99, SourceDB: "lx"}, {PoolID: 10, BusierID: 9, SourceDB: "hw"}, {PoolID: 0, BusierID: 9, SourceDB: "lx"}} {
if err := uc.Apply(ctx, a.PoolID, a.BusierID, a.SourceDB); err == nil {
t.Fatal("unowned application accepted")
}
}
if err := uc.Apply(context.Background(), 10, 9, "lx"); err == nil {
t.Fatal("anonymous request accepted")
}
if gateway.calls != 1 {
t.Fatal("invalid requests reached OA")
}
}
+3 -3
View File
@@ -36,7 +36,7 @@ func (r *activationRepoFake) Spend(_ context.Context, q ActivationSpendQuery) ([
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: []ActivationAccount{{"lx", " Own "}, {"LX", "own"}, {"hw", "Other"}, {"lx", ""}, {"unknown", "Own"}}}
return NewActivationUsecase(r, NewAccountUsecase(a)), r
return NewActivationUsecase(r, NewAccountUsecase(a), NewUserPreferenceUsecase(&preferenceRepoFake{})), r
}
func TestActivationScopeIncludesOwnAccountsOnly(t *testing.T) {
uc, r := activationFixture()
@@ -62,8 +62,8 @@ func TestActivationSpendScopeCutoffAndExactMoney(t *testing.T) {
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)
if err != nil || detail.TotalAmount != "9007199254740993.10" || r.query.Start != start.Unix() || r.query.End == nil || *r.query.End != unbind.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() {
+5 -2
View File
@@ -13,6 +13,7 @@ type DashboardMetrics struct {
Pay string
}
type DashboardData struct {
EventsIncomplete bool
Current, Previous DashboardMetrics
Available bool
EventsAvailable bool
@@ -84,8 +85,7 @@ func (uc *DashboardUsecase) Overview(ctx context.Context, sources []string, peri
}
r.Cards[card.key] = value
}
// The imported SDK channel table has only current state; historical event
// counts must stay unavailable until a timestamped event source is connected.
// OA 在渠道更新事务中记录事件;按事件发生时间统计,不读取当前锁定状态。
for _, card := range []struct {
key string
current, previous int64
@@ -93,6 +93,9 @@ func (uc *DashboardUsecase) Overview(ctx context.Context, sources []string, peri
label := "较上一同期"
if !data.EventsAvailable {
label = "事件数据未接入"
if data.EventsIncomplete {
label = "所选区间事件历史不完整"
}
}
value, err := workbenchCard(num(card.current), num(card.previous), label, data.Available && data.EventsAvailable)
if err != nil {