feat: 迁移商务工作台指标和仪表盘榜单

This commit is contained in:
Yvan
2026-11-04 01:25:51 +08:00
parent 0bef29ae4f
commit cbf0ee9be3
26 changed files with 1220 additions and 22 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.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)
+244
View File
@@ -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)
}
+91
View File
@@ -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("缺数据不能返回空榜单")
}
}