Files
hfb_sys/backend/internal/modules/admindashboard/repository.go
T
yml2213 87d252bbee feat(dashboard): 增加极品专区与保险成交统计
- 仪表盘新增今日极品专区、保险8%、保险4%成交数量
- 展示近7日每日成交趋势和明细
- 按已支付订单口径统计,补充回归测试
2026-11-06 19:09:35 +08:00

497 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package admindashboard
import (
"context"
"sort"
"time"
"hfb_sys/backend/internal/listingstatus"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/timeutil"
"gorm.io/gorm"
)
const (
listingDailyTrendDays = 7
tradingDailyTrendDays = 7
listingChannelWebsite = "站内发布"
listingChannelExternalUnknown = "未填写"
// 成交口径:排除待支付、取消、关闭,与推广归因的成交统计保持一致。
tradingOrderPaidCondition = "status NOT IN ('pending_payment', 'cancelled', 'closed')"
)
type Repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) {
db := r.db.WithContext(ctx)
loc := timeutil.ShanghaiLocation()
now := timeutil.ShanghaiNow()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
metrics := MetricsDTO{}
pending := PendingDTO{}
if err := db.Model(&model.User{}).Count(&metrics.TotalUsers).Error; err != nil {
return nil, err
}
if err := db.Model(&model.User{}).Where("realname_status = ?", "verified").Count(&metrics.VerifiedUsers).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalListing{}).Count(&metrics.TotalListings).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalListing{}).Where("status = ? AND review_status = ?", "published", "approved").Count(&metrics.PublishedListings).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalOrder{}).Count(&metrics.TotalOrders).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalOrder{}).Where("status = ?", "renting").Count(&metrics.RentingOrders).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalOrder{}).Where("created_at >= ?", today).Count(&metrics.TodayOrders).Error; err != nil {
return nil, err
}
if err := db.Model(&model.WalletLedger{}).
Select("COALESCE(SUM(amount_cent), 0)").
Where("created_at >= ?", today).
Scan(&metrics.TodayLedgerAmountCent).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalListing{}).Where("review_status = ?", "pending").Count(&pending.ListingReviews).Error; err != nil {
return nil, err
}
if err := db.Model(&model.Dispute{}).Where("status IN ?", []string{"open", "processing"}).Count(&pending.Disputes).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalOrder{}).Where("status = ? AND handoff_status IN ?", "pending_handoff", []string{"pending_owner", "pending_renter_confirm"}).Count(&pending.PendingHandoffs).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalOrder{}).Where("status IN ?", []string{"pending_checkout_confirm", "pending_checkout_accept"}).Count(&pending.PendingReturnConfirms).Error; err != nil {
return nil, err
}
listingDaily, err := r.listingDailyOverview(ctx, now, listingDailyTrendDays)
if err != nil {
return nil, err
}
tradingDaily, err := r.tradingDailyOverview(ctx, now, tradingDailyTrendDays)
if err != nil {
return nil, err
}
manualEntryToday, err := r.manualEntryToday(ctx, now)
if err != nil {
return nil, err
}
recentOrders, err := r.recentOrders(ctx)
if err != nil {
return nil, err
}
recentDisputes, err := r.recentDisputes(ctx)
if err != nil {
return nil, err
}
return &DashboardDTO{
Metrics: metrics,
Pending: pending,
ListingDaily: listingDaily,
TradingDaily: tradingDaily,
ManualEntryToday: manualEntryToday,
RecentOrders: recentOrders,
RecentDisputes: recentDisputes,
GeneratedAt: now,
}, nil
}
func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, days int) (ListingDailyOverviewDTO, error) {
if days <= 0 {
days = listingDailyTrendDays
}
loc := timeutil.ShanghaiLocation()
now = now.In(loc)
// 右开区间 [start, end)
end := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, 1)
start := end.AddDate(0, 0, -days)
type eventRow struct {
EventType string
Source string
SourceChannel string
CreatedAt time.Time
}
events := make([]eventRow, 0)
db := r.db.WithContext(ctx)
uploadSource := db.Table("listing_uploads").
Select("listing_id, MAX(id) AS upload_id, MAX(NULLIF(source_channel, '')) AS source_channel").
Where("listing_id IS NOT NULL").
Group("listing_id")
if err := db.Table("listing_status_events AS e").
Select(`e.event_type, e.source, e.created_at,
CASE
WHEN lu.upload_id IS NULL THEN ?
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
ELSE lu.source_channel
END AS source_channel`, listingChannelWebsite, listingChannelExternalUnknown).
Joins("LEFT JOIN (?) AS lu ON lu.listing_id = e.listing_id", uploadSource).
Where("e.created_at >= ? AND e.created_at < ?", start, end).
Scan(&events).Error; err != nil {
return ListingDailyOverviewDTO{}, err
}
uploads := make([]listingUploaderRow, 0)
if err := db.Table("listing_uploads").
Select("COALESCE(matched_admin_id, 0) AS uploader_id, uploader_name, created_at").
Where("listing_id IS NOT NULL").
Where("created_at >= ? AND created_at < ?", start, end).
Scan(&uploads).Error; err != nil {
return ListingDailyOverviewDTO{}, err
}
byDay := make(map[string]*listingDailyBucket, days)
byChannel := make(map[listingChannelKey]*listingDailyBucket)
byUploader := make(map[listingUploaderKey]int64)
for _, ev := range events {
key := ev.CreatedAt.In(loc).Format("2006-01-02")
b := byDay[key]
if b == nil {
b = &listingDailyBucket{}
byDay[key] = b
}
if !addListingDailyEvent(b, ev.EventType, ev.Source) {
continue
}
channel := ev.SourceChannel
channelBucketKey := listingChannelKey{date: key, sourceChannel: channel}
cb := byChannel[channelBucketKey]
if cb == nil {
cb = &listingDailyBucket{}
byChannel[channelBucketKey] = cb
}
addListingDailyEvent(cb, ev.EventType, ev.Source)
}
for _, upload := range uploads {
key := upload.CreatedAt.In(loc).Format("2006-01-02")
uploaderKey := listingUploaderKey{date: key, uploaderID: upload.UploaderID, uploaderName: upload.UploaderName}
byUploader[uploaderKey]++
}
trend := make([]ListingDayStatsDTO, 0, days)
for i := days - 1; i >= 0; i-- {
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -i)
key := day.Format("2006-01-02")
item := ListingDayStatsDTO{Date: key}
if b := byDay[key]; b != nil {
item.PublishedCount = b.published
item.ActiveOfflineCount = b.activeOffline
item.TradeLeaveCount = b.tradeLeave
}
trend = append(trend, item)
}
todayStats := ListingDayStatsDTO{Date: now.Format("2006-01-02")}
if len(trend) > 0 {
todayStats = trend[len(trend)-1]
}
channelTrend := listingChannelTrend(byChannel)
uploaderTrend := listingUploaderTrend(byUploader)
todayChannels := make([]ListingChannelDayStatsDTO, 0)
for _, item := range channelTrend {
if item.Date == todayStats.Date {
todayChannels = append(todayChannels, item)
}
}
todayUploaders := make([]ListingUploaderDayStatsDTO, 0)
for _, item := range uploaderTrend {
if item.Date == todayStats.Date {
todayUploaders = append(todayUploaders, item)
}
}
return ListingDailyOverviewDTO{
Today: todayStats,
Trend: trend,
TodayChannels: todayChannels,
ChannelTrend: channelTrend,
TodayUploaders: todayUploaders,
UploaderTrend: uploaderTrend,
Days: days,
Timezone: "Asia/Shanghai",
}, nil
}
type listingUploaderRow struct {
UploaderID uint64
UploaderName string
CreatedAt time.Time
}
type listingChannelKey struct {
date string
sourceChannel string
}
type listingUploaderKey struct {
date string
uploaderID uint64
uploaderName string
}
type listingDailyBucket struct {
published int64
activeOffline int64
tradeLeave int64
}
func addListingDailyEvent(b *listingDailyBucket, eventType, source string) bool {
switch {
case eventType == listingstatus.EventPublished:
b.published++
return true
case isActiveOfflineEvent(eventType, source):
b.activeOffline++
return true
case isTradeLeaveEvent(eventType, source):
b.tradeLeave++
return true
default:
return false
}
}
func listingChannelTrend(rows map[listingChannelKey]*listingDailyBucket) []ListingChannelDayStatsDTO {
keys := make([]listingChannelKey, 0, len(rows))
for key := range rows {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].date != keys[j].date {
return keys[i].date < keys[j].date
}
leftRank := listingChannelRank(keys[i].sourceChannel)
rightRank := listingChannelRank(keys[j].sourceChannel)
if leftRank != rightRank {
return leftRank < rightRank
}
return keys[i].sourceChannel < keys[j].sourceChannel
})
items := make([]ListingChannelDayStatsDTO, 0, len(keys))
for _, key := range keys {
b := rows[key]
items = append(items, ListingChannelDayStatsDTO{
Date: key.date,
SourceChannel: key.sourceChannel,
PublishedCount: b.published,
ActiveOfflineCount: b.activeOffline,
TradeLeaveCount: b.tradeLeave,
})
}
return items
}
func listingUploaderTrend(rows map[listingUploaderKey]int64) []ListingUploaderDayStatsDTO {
keys := make([]listingUploaderKey, 0, len(rows))
for key := range rows {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].date != keys[j].date {
return keys[i].date < keys[j].date
}
if rows[keys[i]] != rows[keys[j]] {
return rows[keys[i]] > rows[keys[j]]
}
if keys[i].uploaderName != keys[j].uploaderName {
return keys[i].uploaderName < keys[j].uploaderName
}
return keys[i].uploaderID < keys[j].uploaderID
})
items := make([]ListingUploaderDayStatsDTO, 0, len(keys))
for _, key := range keys {
items = append(items, ListingUploaderDayStatsDTO{
Date: key.date,
UploaderID: key.uploaderID,
UploaderName: key.uploaderName,
UploadCount: rows[key],
})
}
return items
}
func listingChannelRank(channel string) int {
switch channel {
case "咸鱼":
return 10
case "淘宝":
return 20
case "京东":
return 30
case "QQ":
return 40
case "微信":
return 50
case listingChannelExternalUnknown:
return 90
case listingChannelWebsite:
return 100
default:
return 80
}
}
func isActiveOfflineEvent(eventType, source string) bool {
return eventType == listingstatus.EventOffline &&
(source == listingstatus.SourceSeller || source == listingstatus.SourceAdmin)
}
func isTradeLeaveEvent(eventType, source string) bool {
switch eventType {
case listingstatus.EventRented, listingstatus.EventCompleted, listingstatus.EventSealed:
return true
case listingstatus.EventOffline:
return source == listingstatus.SourceOrder ||
source == listingstatus.SourceDispute ||
source == listingstatus.SourcePickup ||
source == listingstatus.SourceSystem
default:
return false
}
}
// tradingDailyOverview 统计经营成交数据:极品专区成交、保险 8%/4% 成交。
// 订单在下单时已快照 is_premium / compensation_plan / insurance_fee_bps,
// 因此按订单统计不受商品后续修改影响。
func (r *Repository) tradingDailyOverview(ctx context.Context, now time.Time, days int) (TradingDailyOverviewDTO, error) {
if days <= 0 {
days = tradingDailyTrendDays
}
loc := timeutil.ShanghaiLocation()
now = now.In(loc)
end := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, 1)
start := end.AddDate(0, 0, -days)
type row struct {
CreatedAt time.Time
IsPremium bool
CompensationPlan string
InsuranceFeeBps int
}
rows := make([]row, 0)
// 读取区间内订单后在 Go 侧按上海时区归日,避免依赖 MySQL 会话时区。
if err := r.db.WithContext(ctx).Table("rental_orders").
Select("created_at, is_premium, compensation_plan, insurance_fee_bps").
Where(tradingOrderPaidCondition).
Where("created_at >= ? AND created_at < ?", start, end).
Scan(&rows).Error; err != nil {
return TradingDailyOverviewDTO{}, err
}
byDay := make(map[string]TradingDayStatsDTO, days)
for _, item := range rows {
key := item.CreatedAt.In(loc).Format("2006-01-02")
stats := byDay[key]
stats.Date = key
if item.IsPremium {
stats.PremiumOrderCount++
}
if item.CompensationPlan == "insurance" {
switch item.InsuranceFeeBps {
case 800:
stats.Insurance8OrderCount++
case 400:
stats.Insurance4OrderCount++
}
}
byDay[key] = stats
}
trend := make([]TradingDayStatsDTO, 0, days)
for i := days - 1; i >= 0; i-- {
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -i)
key := day.Format("2006-01-02")
item := TradingDayStatsDTO{Date: key}
if v, ok := byDay[key]; ok {
item = v
}
trend = append(trend, item)
}
today := TradingDayStatsDTO{Date: now.Format("2006-01-02")}
if len(trend) > 0 {
today = trend[len(trend)-1]
}
return TradingDailyOverviewDTO{
Today: today,
Trend: trend,
Days: days,
Timezone: "Asia/Shanghai",
}, nil
}
// manualEntryToday 统计今日(北京时间自然日)各客服的手动录单数量。
// 录单表是后来新增的功能表,若迁移尚未执行(表不存在)则降级为空统计,
// 避免连带整个仪表盘不可用。
func (r *Repository) manualEntryToday(ctx context.Context, now time.Time) ([]ManualEntryAgentStatsDTO, error) {
if r.db == nil || !r.db.Migrator().HasTable("cs_manual_entries") {
return []ManualEntryAgentStatsDTO{}, nil
}
loc := timeutil.ShanghaiLocation()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
type row struct {
AdminID uint64
AdminName string
Count int64
}
rows := make([]row, 0)
if err := r.db.WithContext(ctx).Table("cs_manual_entries").
Select("admin_id, MAX(admin_name) AS admin_name, COUNT(*) AS count").
Where("created_at >= ? AND created_at < ?", start, end).
Group("admin_id").
Scan(&rows).Error; err != nil {
return nil, err
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Count != rows[j].Count {
return rows[i].Count > rows[j].Count
}
return rows[i].AdminName < rows[j].AdminName
})
items := make([]ManualEntryAgentStatsDTO, 0, len(rows))
for _, item := range rows {
items = append(items, ManualEntryAgentStatsDTO{
AdminID: item.AdminID,
AdminName: item.AdminName,
Count: item.Count,
})
}
return items, nil
}
func (r *Repository) recentOrders(ctx context.Context) ([]RecentOrderDTO, error) {
rows := make([]RecentOrderDTO, 0)
err := r.db.WithContext(ctx).Table("rental_orders AS o").
Select("o.id, o.order_no, a.title, o.renter_id, o.owner_id, o.status, o.rent_amount_cent, o.deposit_amount_cent, o.created_at").
Joins("JOIN game_accounts AS a ON a.id = o.account_id").
Order("o.id DESC").
Limit(8).
Scan(&rows).Error
return rows, err
}
func (r *Repository) recentDisputes(ctx context.Context) ([]RecentDisputeDTO, error) {
rows := make([]RecentDisputeDTO, 0)
err := r.db.WithContext(ctx).Table("disputes AS d").
Select("d.id, d.order_id, o.order_no, a.title, d.type, d.status, d.initiator_id, d.created_at").
Joins("JOIN rental_orders AS o ON o.id = d.order_id").
Joins("JOIN game_accounts AS a ON a.id = o.account_id").
Order("d.id DESC").
Limit(8).
Scan(&rows).Error
return rows, err
}