Files
olons_admin/server/internal/logic/sys/dashboard.go
T
2026-10-10 20:56:17 +08:00

286 lines
11 KiB
Go

package sys
import (
"context"
"hotgo/internal/dao"
"hotgo/internal/library/hgorm/handler"
"hotgo/internal/model/entity"
"hotgo/internal/model/input/sysin"
"hotgo/internal/service"
"math"
"sort"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
const (
dashboardDimensionGlobal = "global"
dashboardDimensionChannel = "channel"
)
type dashboardMetricDefinition struct {
Code string
Title string
ValueType string
Aggregation string
Description string
RouteName string
Sensitive bool
}
var dashboardMetricDefinitions = []dashboardMetricDefinition{
{Code: "member_total", Title: "用户总量", ValueType: "count", Aggregation: "latest", Description: "截至区间结束日的去重有效会员数", RouteName: ""},
{Code: "member_new", Title: "新增用户", ValueType: "count", Aggregation: "sum", Description: "区间内新增有效会员数", RouteName: ""},
{Code: "recharge_member", Title: "充值用户", ValueType: "count", Aggregation: "sum", Description: "区间内至少成功充值一次的去重会员数", RouteName: ""},
{Code: "product_total", Title: "商品总量", ValueType: "count", Aggregation: "latest", Description: "截至区间结束日的有效商品数", RouteName: ""},
{Code: "trade_order", Title: "订单量", ValueType: "count", Aggregation: "sum", Description: "区间内创建的业务订单数", RouteName: ""},
{Code: "trade_order", Title: "订单金额", ValueType: "amount", Aggregation: "sum", Description: "区间内业务订单总金额", RouteName: "", Sensitive: true},
{Code: "recharge_success", Title: "成功充值", ValueType: "amount", Aggregation: "sum", Description: "区间内已成功入账的充值金额", RouteName: "", Sensitive: true},
{Code: "recharge_pending", Title: "待处理充值", ValueType: "amount", Aggregation: "latest", Description: "截至区间结束日的待处理充值金额", RouteName: "", Sensitive: true},
{Code: "withdrawal_success", Title: "成功提现", ValueType: "amount", Aggregation: "sum", Description: "区间内已成功支付的提现金额", RouteName: "", Sensitive: true},
{Code: "withdrawal_pending", Title: "待处理提现", ValueType: "amount", Aggregation: "latest", Description: "截至区间结束日的待审核及处理中提现金额", RouteName: "", Sensitive: true},
{Code: "commission", Title: "任务佣金", ValueType: "amount", Aggregation: "sum", Description: "区间内结算的任务佣金", RouteName: "", Sensitive: true},
{Code: "referral_commission", Title: "下级返佣", ValueType: "amount", Aggregation: "sum", Description: "区间内结算的层级返佣", RouteName: "", Sensitive: true},
{Code: "interest_transfer", Title: "利息宝转入", ValueType: "amount", Aggregation: "sum", Description: "区间内转入利息宝的金额", RouteName: "", Sensitive: true},
{Code: "wallet_available", Title: "可用余额", ValueType: "amount", Aggregation: "latest", Description: "截至区间结束日的钱包可用余额", RouteName: "", Sensitive: true},
{Code: "wallet_frozen", Title: "冻结余额", ValueType: "amount", Aggregation: "latest", Description: "截至区间结束日的钱包冻结余额", RouteName: "", Sensitive: true},
}
type sSysDashboard struct{}
func NewSysDashboard() *sSysDashboard { return &sSysDashboard{} }
func init() { service.RegisterSysDashboard(NewSysDashboard()) }
func (s *sSysDashboard) Summary(ctx context.Context, in *sysin.DashboardSummaryInp) (res *sysin.DashboardSummaryModel, err error) {
start, end := normalizeDashboardRange(in)
days := int(end.Sub(start).Hours()/24) + 1
previousEnd := start.AddDate(0, 0, -1)
previousStart := previousEnd.AddDate(0, 0, -(days - 1))
currentRows, err := s.queryRows(ctx, start, end)
if err != nil {
return nil, err
}
previousRows, err := s.queryRows(ctx, previousStart, previousEnd)
if err != nil {
return nil, err
}
canViewAmounts := service.AdminRole().Verify(ctx, "/dashboard/amount", "GET")
res = &sysin.DashboardSummaryModel{
Range: sysin.DashboardRangeModel{StartDate: start.Format("Y-m-d"), EndDate: end.Format("Y-m-d")},
GeneratedAt: gtime.Now(),
Metrics: make([]*sysin.DashboardMetricModel, 0, len(dashboardMetricDefinitions)),
Trend: s.buildTrend(start, end, currentRows),
Channels: s.buildChannels(currentRows, previousRows, canViewAmounts),
}
if !canViewAmounts {
for _, point := range res.Trend {
point.OrderAmount = 0
point.RechargeAmount = 0
point.WithdrawalAmount = 0
point.CommissionAmount = 0
}
}
for _, definition := range dashboardMetricDefinitions {
value := aggregateDashboardMetric(currentRows, definition)
previousValue := aggregateDashboardMetric(previousRows, definition)
masked := definition.Sensitive && !canViewAmounts
if masked {
value = 0
previousValue = 0
}
res.Metrics = append(res.Metrics, &sysin.DashboardMetricModel{
Code: definition.Code, Title: definition.Title, Value: value, PreviousValue: previousValue,
ChangeRate: dashboardChangeRate(value, previousValue), ValueType: definition.ValueType,
Masked: masked, Description: definition.Description, RouteName: definition.RouteName,
})
}
res.DataReady, res.DataAsOf = dashboardDataStatus(currentRows)
if res.DataAsOf == nil {
res.DataAsOf = res.GeneratedAt
res.Warning = "当前业务汇总表暂无数据,页面展示零值;系统未使用模拟数据。"
} else {
res.DataDelaySeconds = res.GeneratedAt.Timestamp() - res.DataAsOf.Timestamp()
if res.DataDelaySeconds < 0 {
res.DataDelaySeconds = 0
}
if res.DataDelaySeconds > 3600 {
res.Warning = "看板汇总数据延迟超过1小时,请检查汇总任务。"
}
}
return
}
func normalizeDashboardRange(in *sysin.DashboardSummaryInp) (time.Time, time.Time) {
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
end := start
if in.StartDate != nil && in.EndDate != nil {
start = time.Date(in.StartDate.Year(), time.Month(in.StartDate.Month()), in.StartDate.Day(), 0, 0, 0, 0, now.Location())
end = time.Date(in.EndDate.Year(), time.Month(in.EndDate.Month()), in.EndDate.Day(), 0, 0, 0, 0, now.Location())
}
return start, end
}
func (s *sSysDashboard) queryRows(ctx context.Context, start, end time.Time) (rows []*entity.DashboardDailyStat, err error) {
columns := dao.DashboardDailyStat.Columns()
mod := handler.Model(dao.DashboardDailyStat.Ctx(ctx), &handler.Option{FilterTenant: true}).
WhereBetween(columns.StatDate, start.Format("2006-01-02"), end.Format("2006-01-02")).
WhereIn(columns.DimensionType, []string{dashboardDimensionGlobal, dashboardDimensionChannel}).
OrderAsc(columns.StatDate).OrderAsc(columns.Id)
if err = mod.Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "获取看板汇总数据失败")
}
return
}
func aggregateDashboardMetric(rows []*entity.DashboardDailyStat, definition dashboardMetricDefinition) int64 {
var value int64
var latestDate string
for _, row := range rows {
if row.DimensionType != dashboardDimensionGlobal || row.MetricCode != definition.Code {
continue
}
rowValue := row.MetricCount
if definition.ValueType == "amount" {
rowValue = row.MetricAmount
}
date := ""
if row.StatDate != nil {
date = row.StatDate.Format("Y-m-d")
}
if definition.Aggregation == "latest" {
if date >= latestDate {
latestDate = date
value = rowValue
}
} else {
value += rowValue
}
}
return value
}
func dashboardChangeRate(value, previous int64) float64 {
if previous == 0 {
if value == 0 {
return 0
}
return 100
}
return math.Round((float64(value-previous)/float64(previous))*10000) / 100
}
func (s *sSysDashboard) buildTrend(start, end time.Time, rows []*entity.DashboardDailyStat) []*sysin.DashboardTrendPointModel {
points := make(map[string]*sysin.DashboardTrendPointModel)
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
date := day.Format("2006-01-02")
points[date] = &sysin.DashboardTrendPointModel{Date: date}
}
for _, row := range rows {
if row.DimensionType != dashboardDimensionGlobal || row.StatDate == nil {
continue
}
point := points[row.StatDate.Format("Y-m-d")]
if point == nil {
continue
}
switch row.MetricCode {
case "trade_order":
point.OrderAmount += row.MetricAmount
case "recharge_success":
point.RechargeAmount += row.MetricAmount
case "withdrawal_success":
point.WithdrawalAmount += row.MetricAmount
case "commission":
point.CommissionAmount += row.MetricAmount
}
}
dates := make([]string, 0, len(points))
for date := range points {
dates = append(dates, date)
}
sort.Strings(dates)
result := make([]*sysin.DashboardTrendPointModel, 0, len(dates))
for _, date := range dates {
result = append(result, points[date])
}
return result
}
func (s *sSysDashboard) buildChannels(currentRows, previousRows []*entity.DashboardDailyStat, canViewAmounts bool) []*sysin.DashboardChannelModel {
labels := map[string]string{"wechat": "微信", "alipay": "支付宝", "bank": "银行卡"}
channels := make(map[string]*sysin.DashboardChannelModel, len(labels))
for code, label := range labels {
channels[code] = &sysin.DashboardChannelModel{Channel: code, Label: label, Masked: !canViewAmounts}
}
fillChannelSummary(channels, currentRows, labels, false, !canViewAmounts)
fillChannelSummary(channels, previousRows, labels, true, !canViewAmounts)
codes := make([]string, 0, len(channels))
for code := range channels {
codes = append(codes, code)
}
sort.Strings(codes)
result := make([]*sysin.DashboardChannelModel, 0, len(codes))
for _, code := range codes {
if channels[code].Masked {
channels[code].RechargeAmount = 0
channels[code].WithdrawalAmount = 0
channels[code].PreviousRechargeAmount = 0
channels[code].PreviousWithdrawalAmount = 0
}
result = append(result, channels[code])
}
return result
}
func fillChannelSummary(channels map[string]*sysin.DashboardChannelModel, rows []*entity.DashboardDailyStat, labels map[string]string, previous, masked bool) {
for _, row := range rows {
if row.DimensionType != dashboardDimensionChannel {
continue
}
channel := channels[row.DimensionValue]
if channel == nil {
label := labels[row.DimensionValue]
if label == "" {
label = row.DimensionValue
}
channel = &sysin.DashboardChannelModel{Channel: row.DimensionValue, Label: label, Masked: masked}
channels[row.DimensionValue] = channel
}
switch row.MetricCode {
case "recharge_success":
if previous {
channel.PreviousRechargeCount += row.MetricCount
channel.PreviousRechargeAmount += row.MetricAmount
} else {
channel.RechargeCount += row.MetricCount
channel.RechargeAmount += row.MetricAmount
}
case "withdrawal_success":
if previous {
channel.PreviousWithdrawalCount += row.MetricCount
channel.PreviousWithdrawalAmount += row.MetricAmount
} else {
channel.WithdrawalCount += row.MetricCount
channel.WithdrawalAmount += row.MetricAmount
}
}
}
}
func dashboardDataStatus(rows []*entity.DashboardDailyStat) (bool, *gtime.Time) {
var latest *gtime.Time
for _, row := range rows {
if row.CalculatedAt != nil && (latest == nil || row.CalculatedAt.After(latest)) {
latest = row.CalculatedAt
}
}
return len(rows) > 0, latest
}