增加渠道上传预统计
This commit is contained in:
@@ -24,17 +24,27 @@ type MetricsDTO struct {
|
||||
|
||||
// ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。
|
||||
type ListingDailyOverviewDTO struct {
|
||||
Today ListingDayStatsDTO `json:"today"`
|
||||
Trend []ListingDayStatsDTO `json:"trend"`
|
||||
Days int `json:"days"`
|
||||
Timezone string `json:"timezone"`
|
||||
Today ListingDayStatsDTO `json:"today"`
|
||||
Trend []ListingDayStatsDTO `json:"trend"`
|
||||
TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"`
|
||||
ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"`
|
||||
Days int `json:"days"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
type ListingDayStatsDTO struct {
|
||||
Date string `json:"date"` // YYYY-MM-DD(上海时区)
|
||||
PublishedCount int64 `json:"published_count"`
|
||||
ActiveOfflineCount int64 `json:"active_offline_count"` // 号主 + 后台主动下架
|
||||
TradeLeaveCount int64 `json:"trade_leave_count"` // rented / completed / sealed / 交易侧归档下架
|
||||
Date string `json:"date"` // YYYY-MM-DD(上海时区)
|
||||
PublishedCount int64 `json:"published_count"`
|
||||
ActiveOfflineCount int64 `json:"active_offline_count"` // 号主 + 后台主动下架
|
||||
TradeLeaveCount int64 `json:"trade_leave_count"` // rented / completed / sealed / 交易侧归档下架
|
||||
}
|
||||
|
||||
type ListingChannelDayStatsDTO struct {
|
||||
Date string `json:"date"` // YYYY-MM-DD(上海时区)
|
||||
SourceChannel string `json:"source_channel"`
|
||||
PublishedCount int64 `json:"published_count"`
|
||||
ActiveOfflineCount int64 `json:"active_offline_count"`
|
||||
TradeLeaveCount int64 `json:"trade_leave_count"`
|
||||
}
|
||||
|
||||
type PendingDTO struct {
|
||||
|
||||
@@ -2,6 +2,7 @@ package admindashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/listingstatus"
|
||||
@@ -11,7 +12,11 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const listingDailyTrendDays = 7
|
||||
const (
|
||||
listingDailyTrendDays = 7
|
||||
listingChannelWebsite = "站内发布"
|
||||
listingChannelExternalUnknown = "未填写"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
@@ -102,39 +107,50 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
|
||||
start := end.AddDate(0, 0, -days)
|
||||
|
||||
type eventRow struct {
|
||||
EventType string
|
||||
Source string
|
||||
CreatedAt time.Time
|
||||
EventType string
|
||||
Source string
|
||||
SourceChannel string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
events := make([]eventRow, 0)
|
||||
if err := r.db.WithContext(ctx).Model(&model.ListingStatusEvent{}).
|
||||
Select("event_type, source, created_at").
|
||||
Where("created_at >= ? AND created_at < ?", start, end).
|
||||
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
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
published int64
|
||||
activeOffline int64
|
||||
tradeLeave int64
|
||||
}
|
||||
byDay := make(map[string]*bucket, days)
|
||||
byDay := make(map[string]*listingDailyBucket, days)
|
||||
byChannel := make(map[listingChannelKey]*listingDailyBucket)
|
||||
for _, ev := range events {
|
||||
key := ev.CreatedAt.In(loc).Format("2006-01-02")
|
||||
b := byDay[key]
|
||||
if b == nil {
|
||||
b = &bucket{}
|
||||
b = &listingDailyBucket{}
|
||||
byDay[key] = b
|
||||
}
|
||||
switch {
|
||||
case ev.EventType == listingstatus.EventPublished:
|
||||
b.published++
|
||||
case isActiveOfflineEvent(ev.EventType, ev.Source):
|
||||
b.activeOffline++
|
||||
case isTradeLeaveEvent(ev.EventType, ev.Source):
|
||||
b.tradeLeave++
|
||||
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)
|
||||
}
|
||||
|
||||
trend := make([]ListingDayStatsDTO, 0, days)
|
||||
@@ -154,14 +170,101 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
|
||||
if len(trend) > 0 {
|
||||
todayStats = trend[len(trend)-1]
|
||||
}
|
||||
channelTrend := listingChannelTrend(byChannel)
|
||||
todayChannels := make([]ListingChannelDayStatsDTO, 0)
|
||||
for _, item := range channelTrend {
|
||||
if item.Date == todayStats.Date {
|
||||
todayChannels = append(todayChannels, item)
|
||||
}
|
||||
}
|
||||
return ListingDailyOverviewDTO{
|
||||
Today: todayStats,
|
||||
Trend: trend,
|
||||
Days: days,
|
||||
Timezone: "Asia/Shanghai",
|
||||
Today: todayStats,
|
||||
Trend: trend,
|
||||
TodayChannels: todayChannels,
|
||||
ChannelTrend: channelTrend,
|
||||
Days: days,
|
||||
Timezone: "Asia/Shanghai",
|
||||
}, nil
|
||||
}
|
||||
|
||||
type listingChannelKey struct {
|
||||
date string
|
||||
sourceChannel 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 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)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package admindashboard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/listingstatus"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/timeutil"
|
||||
)
|
||||
|
||||
func TestListingDailyOverviewGroupsBySourceChannel(t *testing.T) {
|
||||
db := database.NewTestDB()
|
||||
if err := db.AutoMigrate(&model.ListingStatusEvent{}, &model.ListingUpload{}); err != nil {
|
||||
t.Fatalf("AutoMigrate() error = %v", err)
|
||||
}
|
||||
loc := timeutil.ShanghaiLocation()
|
||||
now := time.Date(2026, 7, 25, 12, 0, 0, 0, loc)
|
||||
listingID1 := uint64(1)
|
||||
listingID2 := uint64(2)
|
||||
listingID4 := uint64(4)
|
||||
|
||||
uploads := []model.ListingUpload{
|
||||
{ListingID: &listingID1, UploaderName: "客服1", SourceChannel: "淘宝"},
|
||||
{ListingID: &listingID2, UploaderName: "客服2", SourceChannel: "微信"},
|
||||
{ListingID: &listingID4, UploaderName: "客服3"},
|
||||
}
|
||||
if err := db.Create(&uploads).Error; err != nil {
|
||||
t.Fatalf("create uploads error = %v", err)
|
||||
}
|
||||
|
||||
events := []model.ListingStatusEvent{
|
||||
{ListingID: listingID1, EventType: listingstatus.EventPublished, Source: listingstatus.SourceReview, CreatedAt: now},
|
||||
{ListingID: listingID1, EventType: listingstatus.EventOffline, Source: listingstatus.SourceAdmin, CreatedAt: now.Add(time.Minute)},
|
||||
{ListingID: listingID2, EventType: listingstatus.EventRented, Source: listingstatus.SourceOrder, CreatedAt: now.Add(2 * time.Minute)},
|
||||
{ListingID: 3, EventType: listingstatus.EventPublished, Source: listingstatus.SourceSeller, CreatedAt: now.Add(3 * time.Minute)},
|
||||
{ListingID: listingID4, EventType: listingstatus.EventPublished, Source: listingstatus.SourceReview, CreatedAt: now.Add(4 * time.Minute)},
|
||||
}
|
||||
if err := db.Create(&events).Error; err != nil {
|
||||
t.Fatalf("create events error = %v", err)
|
||||
}
|
||||
|
||||
overview, err := NewRepository(db).listingDailyOverview(t.Context(), now, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("listingDailyOverview() error = %v", err)
|
||||
}
|
||||
if overview.Today.PublishedCount != 3 || overview.Today.ActiveOfflineCount != 1 || overview.Today.TradeLeaveCount != 1 {
|
||||
t.Fatalf("today stats = %#v", overview.Today)
|
||||
}
|
||||
|
||||
statsByChannel := make(map[string]ListingChannelDayStatsDTO)
|
||||
for _, item := range overview.TodayChannels {
|
||||
statsByChannel[item.SourceChannel] = item
|
||||
}
|
||||
assertChannelStats(t, statsByChannel["淘宝"], 1, 1, 0)
|
||||
assertChannelStats(t, statsByChannel["微信"], 0, 0, 1)
|
||||
assertChannelStats(t, statsByChannel[listingChannelWebsite], 1, 0, 0)
|
||||
assertChannelStats(t, statsByChannel[listingChannelExternalUnknown], 1, 0, 0)
|
||||
}
|
||||
|
||||
func assertChannelStats(t *testing.T, item ListingChannelDayStatsDTO, published, offline, tradeLeave int64) {
|
||||
t.Helper()
|
||||
if item.PublishedCount != published || item.ActiveOfflineCount != offline || item.TradeLeaveCount != tradeLeave {
|
||||
t.Fatalf("channel %q stats = %#v, want published=%d offline=%d tradeLeave=%d", item.SourceChannel, item, published, offline, tradeLeave)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user