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, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase)
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWorkbenchSource = errors.BadRequest("SW_WORKBENCH_SOURCE", "请选择所属平台后查看数据")
|
||||
ErrWorkbenchPeriod = errors.BadRequest("SW_WORKBENCH_PERIOD", "时间维度无效,应为 today/yesterday/last7/last30")
|
||||
ErrWorkbenchUnavailable = errors.BadRequest("SW_WORKBENCH_UNAVAILABLE", "工作台所需业务数据尚未接入")
|
||||
)
|
||||
|
||||
type WorkbenchRankKind string
|
||||
|
||||
const (
|
||||
WorkbenchRegister WorkbenchRankKind = "register"
|
||||
WorkbenchPayHigh WorkbenchRankKind = "payHigh"
|
||||
WorkbenchPayLow WorkbenchRankKind = "payLow"
|
||||
WorkbenchUnlogin WorkbenchRankKind = "unlogin"
|
||||
)
|
||||
|
||||
type WorkbenchWindow struct{ Today, Yesterday, MonthStart, PreviousMonthStart, PreviousMonthEnd time.Time }
|
||||
type WorkbenchDay struct {
|
||||
Counts PortraitCounts
|
||||
Pay string
|
||||
ActiveChannels, NewChannels int64
|
||||
}
|
||||
type WorkbenchTotals struct {
|
||||
Today, Yesterday WorkbenchDay
|
||||
MonthPay, PreviousMonthPay string
|
||||
Available bool
|
||||
}
|
||||
type WorkbenchCard struct {
|
||||
Value, CompareValue, ChangeRate *string
|
||||
CompareLabel string
|
||||
}
|
||||
type WorkbenchSummary struct {
|
||||
TodayRegister, TodayActive, TodayPay, MonthPay, ActivePromote, NewPromote *WorkbenchCard
|
||||
Available bool
|
||||
}
|
||||
type WorkbenchRankRow struct {
|
||||
AnalyticsKey
|
||||
Rank int
|
||||
Name, Value string
|
||||
CreatedUnix, LastLoginUnix *int64
|
||||
}
|
||||
type WorkbenchRankData struct {
|
||||
Rows []*WorkbenchRankRow
|
||||
Available bool
|
||||
}
|
||||
type WorkbenchRepo interface {
|
||||
Summary(context.Context, AnalyticsScope, []string, WorkbenchWindow) (*WorkbenchTotals, error)
|
||||
Ranks(context.Context, AnalyticsScope, AnalyticsFilter, WorkbenchRankKind) (*WorkbenchRankData, error)
|
||||
}
|
||||
type WorkbenchUsecase struct {
|
||||
repo WorkbenchRepo
|
||||
accounts *AccountUsecase
|
||||
preferences *UserPreferenceUsecase
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewWorkbenchUsecase(repo WorkbenchRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *WorkbenchUsecase {
|
||||
return &WorkbenchUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
|
||||
}
|
||||
func (uc *WorkbenchUsecase) scope(ctx context.Context, sources []string) (AnalyticsScope, error) {
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return AnalyticsScope{}, err
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
return AnalyticsScope{}, ErrWorkbenchSource
|
||||
}
|
||||
pref, err := uc.preferences.Current(ctx)
|
||||
if err != nil {
|
||||
return AnalyticsScope{}, err
|
||||
}
|
||||
viewer := portraitUserScope(account)
|
||||
viewer.All = actor.AuthorityID == 888
|
||||
return AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}, nil
|
||||
}
|
||||
func workbenchWindow(now time.Time) WorkbenchWindow {
|
||||
now = now.In(portraitLocation)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, portraitLocation)
|
||||
month := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, portraitLocation)
|
||||
previous := month.AddDate(0, -1, 0)
|
||||
lastDay := month.AddDate(0, 0, -1).Day()
|
||||
previousEnd := previous.AddDate(0, 0, min(today.Day(), lastDay)-1)
|
||||
return WorkbenchWindow{Today: today, Yesterday: today.AddDate(0, 0, -1), MonthStart: month, PreviousMonthStart: previous, PreviousMonthEnd: previousEnd}
|
||||
}
|
||||
func workbenchCard(current, previous, label string, available bool) (*WorkbenchCard, error) {
|
||||
card := &WorkbenchCard{CompareLabel: label}
|
||||
if !available {
|
||||
return card, nil
|
||||
}
|
||||
cur, err := portraitNumber(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prev, err := portraitNumber(previous)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
card.Value, card.CompareValue = stringPointer(portraitDecimal(cur, 2)), stringPointer(portraitDecimal(prev, 2))
|
||||
cur, _ = portraitNumber(*card.Value)
|
||||
prev, _ = portraitNumber(*card.CompareValue)
|
||||
if prev.Sign() != 0 {
|
||||
diff := new(big.Rat).Sub(cur, prev)
|
||||
diff.Mul(diff, big.NewRat(100, 1))
|
||||
diff.Quo(diff, prev)
|
||||
card.ChangeRate = stringPointer(portraitDecimal(diff, 1))
|
||||
}
|
||||
return card, nil
|
||||
}
|
||||
func (uc *WorkbenchUsecase) Summary(ctx context.Context, sources []string) (*WorkbenchSummary, error) {
|
||||
scope, err := uc.scope(ctx, sources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := uc.repo.Summary(ctx, scope, sources, workbenchWindow(uc.now()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.Today.Counts, data.Yesterday.Counts = analyticsCap(data.Today.Counts), analyticsCap(data.Yesterday.Counts)
|
||||
integer := func(n int64) string { return big.NewInt(n).String() }
|
||||
out := &WorkbenchSummary{Available: data.Available}
|
||||
for _, item := range []struct {
|
||||
field **WorkbenchCard
|
||||
current, previous, label string
|
||||
}{
|
||||
{&out.TodayRegister, integer(data.Today.Counts.Register), integer(data.Yesterday.Counts.Register), "环比昨日"},
|
||||
{&out.TodayActive, integer(data.Today.Counts.Active), integer(data.Yesterday.Counts.Active), "环比昨日"},
|
||||
{&out.TodayPay, data.Today.Pay, data.Yesterday.Pay, "环比昨日"},
|
||||
{&out.MonthPay, data.MonthPay, data.PreviousMonthPay, "环比上月"},
|
||||
{&out.ActivePromote, integer(data.Today.ActiveChannels), integer(data.Yesterday.ActiveChannels), "环比昨日"},
|
||||
{&out.NewPromote, integer(data.Today.NewChannels), integer(data.Yesterday.NewChannels), "环比昨日"},
|
||||
} {
|
||||
card, err := workbenchCard(item.current, item.previous, item.label, data.Available)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
*item.field = card
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (uc *WorkbenchUsecase) Ranks(ctx context.Context, sources []string, period string, kind WorkbenchRankKind) ([]*WorkbenchRankRow, error) {
|
||||
scope, err := uc.scope(ctx, sources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind != WorkbenchRegister && kind != WorkbenchPayHigh && kind != WorkbenchPayLow && kind != WorkbenchUnlogin {
|
||||
return nil, ErrAnalyticsInput
|
||||
}
|
||||
period = strings.ToLower(strings.TrimSpace(period))
|
||||
if period == "" {
|
||||
period = "today"
|
||||
if kind == WorkbenchPayLow {
|
||||
period = "last30"
|
||||
}
|
||||
}
|
||||
w := workbenchWindow(uc.now())
|
||||
begin, end := w.Today, w.Today
|
||||
if kind != WorkbenchUnlogin {
|
||||
switch period {
|
||||
case "today":
|
||||
case "yesterday":
|
||||
begin, end = w.Yesterday, w.Yesterday
|
||||
case "last7":
|
||||
begin = end.AddDate(0, 0, -6)
|
||||
case "last30":
|
||||
begin = end.AddDate(0, 0, -29)
|
||||
default:
|
||||
return nil, ErrWorkbenchPeriod
|
||||
}
|
||||
}
|
||||
data, err := uc.repo.Ranks(ctx, scope, AnalyticsFilter{Sources: sources, Begin: begin.Format("2006-01-02"), End: end.Format("2006-01-02")}, kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !data.Available {
|
||||
return nil, ErrWorkbenchUnavailable
|
||||
}
|
||||
rows := make([]*WorkbenchRankRow, 0, len(data.Rows))
|
||||
values := map[*WorkbenchRankRow]*big.Rat{}
|
||||
for _, row := range data.Rows {
|
||||
if kind == WorkbenchUnlogin {
|
||||
row.Value = big.NewInt(workbenchUnloginDays(row, w.Today)).String()
|
||||
}
|
||||
n, err := portraitNumber(row.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind == WorkbenchRegister && n.Cmp(big.NewRat(2147483647, 1)) > 0 {
|
||||
n = big.NewRat(2147483647, 1)
|
||||
}
|
||||
if (kind == WorkbenchRegister || kind == WorkbenchPayHigh) && n.Sign() <= 0 {
|
||||
continue
|
||||
}
|
||||
values[row] = n
|
||||
rows = append(rows, row)
|
||||
}
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
a, b := rows[i], rows[j]
|
||||
if c := values[a].Cmp(values[b]); c != 0 {
|
||||
if kind == WorkbenchPayLow {
|
||||
return c < 0
|
||||
}
|
||||
return c > 0
|
||||
}
|
||||
if a.ID != b.ID {
|
||||
return a.ID < b.ID
|
||||
}
|
||||
return a.SourceDB < b.SourceDB
|
||||
})
|
||||
rows = rows[:min(10, len(rows))]
|
||||
for i, row := range rows {
|
||||
row.Rank = i + 1
|
||||
row.Name = (&LedgerNames{PromoteID: &row.ID, PromoteAccount: row.Name}).DisplayPromote()
|
||||
if kind != WorkbenchUnlogin {
|
||||
row.Value = portraitDecimal(values[row], 2)
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
func workbenchUnloginDays(row *WorkbenchRankRow, today time.Time) int64 {
|
||||
stamp := row.LastLoginUnix
|
||||
if stamp == nil || *stamp <= 0 {
|
||||
stamp = row.CreatedUnix
|
||||
}
|
||||
if stamp == nil || *stamp <= 0 {
|
||||
return 99999
|
||||
}
|
||||
date := time.Unix(*stamp, 0).In(portraitLocation)
|
||||
date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, portraitLocation)
|
||||
if date.After(today) {
|
||||
return 0
|
||||
}
|
||||
return min(int64(today.Sub(date).Hours()/24), 2147483647)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type workbenchRepoFake struct {
|
||||
totals *WorkbenchTotals
|
||||
data *WorkbenchRankData
|
||||
window WorkbenchWindow
|
||||
filter AnalyticsFilter
|
||||
scope AnalyticsScope
|
||||
}
|
||||
|
||||
func (r *workbenchRepoFake) Summary(_ context.Context, s AnalyticsScope, _ []string, w WorkbenchWindow) (*WorkbenchTotals, error) {
|
||||
r.window, r.scope = w, s
|
||||
c := *r.totals
|
||||
return &c, nil
|
||||
}
|
||||
func (r *workbenchRepoFake) Ranks(_ context.Context, s AnalyticsScope, f AnalyticsFilter, _ WorkbenchRankKind) (*WorkbenchRankData, error) {
|
||||
r.filter, r.scope = f, s
|
||||
out := &WorkbenchRankData{Available: r.data.Available}
|
||||
for _, row := range r.data.Rows {
|
||||
c := *row
|
||||
out.Rows = append(out.Rows, &c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func workbenchFixture() (*WorkbenchUsecase, *workbenchRepoFake) {
|
||||
r := &workbenchRepoFake{totals: &WorkbenchTotals{Available: true, Today: WorkbenchDay{Counts: PortraitCounts{Register: 3, Active: 4}, Pay: "20.005", ActiveChannels: 2, NewChannels: 1}, Yesterday: WorkbenchDay{Counts: PortraitCounts{Register: 2}, Pay: "10", ActiveChannels: 1}, MonthPay: "300", PreviousMonthPay: "200"}, data: &WorkbenchRankData{Available: true}}
|
||||
a := &accountRepoFake{accounts: map[uint]*Account{7: {ID: 7, Level: 1, IDPath: "0"}}}
|
||||
uc := NewWorkbenchUsecase(r, NewAccountUsecase(a), NewUserPreferenceUsecase(&channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}))
|
||||
uc.now = func() time.Time { return time.Date(2026, 3, 31, 9, 0, 0, 0, portraitLocation) }
|
||||
return uc, r
|
||||
}
|
||||
func TestWorkbenchSummaryCalendarAndRatios(t *testing.T) {
|
||||
uc, r := workbenchFixture()
|
||||
out, err := uc.Summary(accountContext(7, 901), []string{"lx"})
|
||||
if err != nil || *out.TodayRegister.Value != "3.00" || *out.TodayRegister.ChangeRate != "50.0" || out.TodayActive.ChangeRate != nil || *out.TodayPay.Value != "20.01" || *out.TodayPay.ChangeRate != "100.1" || *out.MonthPay.ChangeRate != "50.0" || *out.ActivePromote.ChangeRate != "100.0" {
|
||||
t.Fatalf("卡片精度或环比错误: %+v %v", out, err)
|
||||
}
|
||||
if r.window.PreviousMonthEnd.Format("2006-01-02") != "2026-02-28" || r.window.PreviousMonthStart.Format("2006-01-02") != "2026-02-01" {
|
||||
t.Fatal("上月同期需要截断至月末")
|
||||
}
|
||||
r.totals.Available = false
|
||||
out, err = uc.Summary(accountContext(7, 901), []string{"lx"})
|
||||
if err != nil || out.TodayPay.Value != nil || out.TodayPay.CompareValue != nil || out.Available {
|
||||
t.Fatal("缺表不能返回零卡片")
|
||||
}
|
||||
if _, err := uc.Summary(accountContext(7, 901), nil); err != ErrWorkbenchSource {
|
||||
t.Fatal("必须选择平台")
|
||||
}
|
||||
}
|
||||
func TestWorkbenchRankLimitsExactSortDefaultsAndUnlogin(t *testing.T) {
|
||||
uc, r := workbenchFixture()
|
||||
ctx := accountContext(7, 901)
|
||||
for i := 0; i < 14; i++ {
|
||||
r.data.Rows = append(r.data.Rows, &WorkbenchRankRow{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: int64(i)}, Value: fmt.Sprint(i)})
|
||||
}
|
||||
rows, err := uc.Ranks(ctx, []string{"lx"}, "", WorkbenchPayHigh)
|
||||
if err != nil || len(rows) != 10 || rows[0].ID != 13 || rows[9].Rank != 10 {
|
||||
t.Fatal("榜单排序或 TOP10 截断错误")
|
||||
}
|
||||
rows, err = uc.Ranks(ctx, []string{"lx"}, "", WorkbenchPayLow)
|
||||
if err != nil || rows[0].ID != 0 || rows[0].Value != "0.00" || rows[0].Name != "官方渠道" || r.filter.Begin != "2026-03-02" {
|
||||
t.Fatal("最低榜应包含零值,默认近30天")
|
||||
}
|
||||
r.data.Rows = []*WorkbenchRankRow{{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: 1}, Value: "9007199254740993.00"}, {AnalyticsKey: AnalyticsKey{SourceDB: "hw", ID: 2}, Value: "9007199254740992.99"}}
|
||||
rows, err = uc.Ranks(ctx, []string{"lx", "hw"}, "last7", WorkbenchPayHigh)
|
||||
if err != nil || rows[0].ID != 1 || r.filter.Begin != "2026-03-25" {
|
||||
t.Fatal("金额排序丢失精度或近7天范围错误")
|
||||
}
|
||||
if _, err := uc.Ranks(ctx, []string{"lx"}, "invalid", WorkbenchRegister); err != ErrWorkbenchPeriod {
|
||||
t.Fatal("无效时间维度必须拒绝")
|
||||
}
|
||||
today := workbenchWindow(uc.now()).Today
|
||||
created := today.AddDate(0, 0, -5).Unix()
|
||||
future := today.AddDate(0, 0, 1).Unix()
|
||||
r.data.Rows = []*WorkbenchRankRow{{AnalyticsKey: AnalyticsKey{ID: 1}}, {AnalyticsKey: AnalyticsKey{ID: 2}, CreatedUnix: &created}, {AnalyticsKey: AnalyticsKey{ID: 3}, CreatedUnix: &created, LastLoginUnix: &future}}
|
||||
rows, err = uc.Ranks(ctx, []string{"lx"}, "ignored", WorkbenchUnlogin)
|
||||
if err != nil || rows[0].Value != "99999" || rows[1].Value != "5" || rows[2].Value != "0" {
|
||||
t.Fatal("未登录回退创建时间、无时间或未来时间错误")
|
||||
}
|
||||
r.data.Available = false
|
||||
if _, err := uc.Ranks(ctx, []string{"lx"}, "", WorkbenchUnlogin); err != ErrWorkbenchUnavailable {
|
||||
t.Fatal("缺数据不能返回空榜单")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user