增加渠道上传预统计
This commit is contained in:
@@ -51,6 +51,7 @@ func (RentalListing) TableName() string {
|
|||||||
type ListingUpload struct {
|
type ListingUpload struct {
|
||||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
UploaderName string `gorm:"size:64;not null;index" json:"uploader_name"`
|
UploaderName string `gorm:"size:64;not null;index" json:"uploader_name"`
|
||||||
|
SourceChannel string `gorm:"size:32;not null;default:'';index" json:"source_channel"`
|
||||||
MatchedAdminID *uint64 `gorm:"index" json:"matched_admin_id"`
|
MatchedAdminID *uint64 `gorm:"index" json:"matched_admin_id"`
|
||||||
OwnerID *uint64 `gorm:"index" json:"owner_id"`
|
OwnerID *uint64 `gorm:"index" json:"owner_id"`
|
||||||
ClientUploadTime *time.Time `json:"client_upload_time"`
|
ClientUploadTime *time.Time `json:"client_upload_time"`
|
||||||
|
|||||||
@@ -24,17 +24,27 @@ type MetricsDTO struct {
|
|||||||
|
|
||||||
// ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。
|
// ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。
|
||||||
type ListingDailyOverviewDTO struct {
|
type ListingDailyOverviewDTO struct {
|
||||||
Today ListingDayStatsDTO `json:"today"`
|
Today ListingDayStatsDTO `json:"today"`
|
||||||
Trend []ListingDayStatsDTO `json:"trend"`
|
Trend []ListingDayStatsDTO `json:"trend"`
|
||||||
Days int `json:"days"`
|
TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"`
|
||||||
Timezone string `json:"timezone"`
|
ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"`
|
||||||
|
Days int `json:"days"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListingDayStatsDTO struct {
|
type ListingDayStatsDTO struct {
|
||||||
Date string `json:"date"` // YYYY-MM-DD(上海时区)
|
Date string `json:"date"` // YYYY-MM-DD(上海时区)
|
||||||
PublishedCount int64 `json:"published_count"`
|
PublishedCount int64 `json:"published_count"`
|
||||||
ActiveOfflineCount int64 `json:"active_offline_count"` // 号主 + 后台主动下架
|
ActiveOfflineCount int64 `json:"active_offline_count"` // 号主 + 后台主动下架
|
||||||
TradeLeaveCount int64 `json:"trade_leave_count"` // rented / completed / sealed / 交易侧归档下架
|
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 {
|
type PendingDTO struct {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package admindashboard
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/listingstatus"
|
"hfb_sys/backend/internal/listingstatus"
|
||||||
@@ -11,7 +12,11 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const listingDailyTrendDays = 7
|
const (
|
||||||
|
listingDailyTrendDays = 7
|
||||||
|
listingChannelWebsite = "站内发布"
|
||||||
|
listingChannelExternalUnknown = "未填写"
|
||||||
|
)
|
||||||
|
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
@@ -102,39 +107,50 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
|
|||||||
start := end.AddDate(0, 0, -days)
|
start := end.AddDate(0, 0, -days)
|
||||||
|
|
||||||
type eventRow struct {
|
type eventRow struct {
|
||||||
EventType string
|
EventType string
|
||||||
Source string
|
Source string
|
||||||
CreatedAt time.Time
|
SourceChannel string
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
events := make([]eventRow, 0)
|
events := make([]eventRow, 0)
|
||||||
if err := r.db.WithContext(ctx).Model(&model.ListingStatusEvent{}).
|
db := r.db.WithContext(ctx)
|
||||||
Select("event_type, source, created_at").
|
uploadSource := db.Table("listing_uploads").
|
||||||
Where("created_at >= ? AND created_at < ?", start, end).
|
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 {
|
Scan(&events).Error; err != nil {
|
||||||
return ListingDailyOverviewDTO{}, err
|
return ListingDailyOverviewDTO{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
type bucket struct {
|
byDay := make(map[string]*listingDailyBucket, days)
|
||||||
published int64
|
byChannel := make(map[listingChannelKey]*listingDailyBucket)
|
||||||
activeOffline int64
|
|
||||||
tradeLeave int64
|
|
||||||
}
|
|
||||||
byDay := make(map[string]*bucket, days)
|
|
||||||
for _, ev := range events {
|
for _, ev := range events {
|
||||||
key := ev.CreatedAt.In(loc).Format("2006-01-02")
|
key := ev.CreatedAt.In(loc).Format("2006-01-02")
|
||||||
b := byDay[key]
|
b := byDay[key]
|
||||||
if b == nil {
|
if b == nil {
|
||||||
b = &bucket{}
|
b = &listingDailyBucket{}
|
||||||
byDay[key] = b
|
byDay[key] = b
|
||||||
}
|
}
|
||||||
switch {
|
if !addListingDailyEvent(b, ev.EventType, ev.Source) {
|
||||||
case ev.EventType == listingstatus.EventPublished:
|
continue
|
||||||
b.published++
|
|
||||||
case isActiveOfflineEvent(ev.EventType, ev.Source):
|
|
||||||
b.activeOffline++
|
|
||||||
case isTradeLeaveEvent(ev.EventType, ev.Source):
|
|
||||||
b.tradeLeave++
|
|
||||||
}
|
}
|
||||||
|
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)
|
trend := make([]ListingDayStatsDTO, 0, days)
|
||||||
@@ -154,14 +170,101 @@ func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, da
|
|||||||
if len(trend) > 0 {
|
if len(trend) > 0 {
|
||||||
todayStats = trend[len(trend)-1]
|
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{
|
return ListingDailyOverviewDTO{
|
||||||
Today: todayStats,
|
Today: todayStats,
|
||||||
Trend: trend,
|
Trend: trend,
|
||||||
Days: days,
|
TodayChannels: todayChannels,
|
||||||
Timezone: "Asia/Shanghai",
|
ChannelTrend: channelTrend,
|
||||||
|
Days: days,
|
||||||
|
Timezone: "Asia/Shanghai",
|
||||||
}, nil
|
}, 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 {
|
func isActiveOfflineEvent(eventType, source string) bool {
|
||||||
return eventType == listingstatus.EventOffline &&
|
return eventType == listingstatus.EventOffline &&
|
||||||
(source == listingstatus.SourceSeller || source == listingstatus.SourceAdmin)
|
(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ type ListingDTO struct {
|
|||||||
OwnerID uint64 `json:"owner_id"`
|
OwnerID uint64 `json:"owner_id"`
|
||||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||||
OwnerNickname string `json:"owner_nickname,omitempty"`
|
OwnerNickname string `json:"owner_nickname,omitempty"`
|
||||||
|
SourceChannel string `json:"source_channel,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
GameName string `json:"game_name"`
|
GameName string `json:"game_name"`
|
||||||
@@ -149,10 +150,12 @@ type AdminActionRequest struct {
|
|||||||
type AuditMeta = auditlog.Meta
|
type AuditMeta = auditlog.Meta
|
||||||
|
|
||||||
type ExternalUploadRequest struct {
|
type ExternalUploadRequest struct {
|
||||||
UploadTime int64 `json:"uploadTime"`
|
UploadTime int64 `json:"uploadTime"`
|
||||||
UploaderName string `json:"uploaderName"`
|
UploaderName string `json:"uploaderName"`
|
||||||
Uploaderame string `json:"uploaderame"`
|
Uploaderame string `json:"uploaderame"`
|
||||||
Data json.RawMessage `json:"data"`
|
SourceChannel string `json:"sourceChannel"`
|
||||||
|
SourceChannelSnake string `json:"source_channel"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r ExternalUploadRequest) normalizedUploaderName() string {
|
func (r ExternalUploadRequest) normalizedUploaderName() string {
|
||||||
@@ -162,6 +165,13 @@ func (r ExternalUploadRequest) normalizedUploaderName() string {
|
|||||||
return strings.TrimSpace(r.Uploaderame)
|
return strings.TrimSpace(r.Uploaderame)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r ExternalUploadRequest) normalizedSourceChannel() string {
|
||||||
|
if channel := normalizeExternalSourceChannel(r.SourceChannel); channel != "" {
|
||||||
|
return channel
|
||||||
|
}
|
||||||
|
return normalizeExternalSourceChannel(r.SourceChannelSnake)
|
||||||
|
}
|
||||||
|
|
||||||
type ExternalAccountData struct {
|
type ExternalAccountData struct {
|
||||||
LoginMethod string `json:"loginMethod"`
|
LoginMethod string `json:"loginMethod"`
|
||||||
Rank string `json:"rank"`
|
Rank string `json:"rank"`
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var externalOnlineTimePattern = regexp.MustCompile(`^\D*(\d{1,2})(?:\s*[::点.]\s*(\d{1,2}))?\D+(\d{1,2})(?:\s*[::点.]\s*(\d{1,2}))?\D*$`)
|
||||||
|
|
||||||
func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRequest, meta ExternalUploadMeta) (*ExternalUploadResponse, error) {
|
func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRequest, meta ExternalUploadMeta) (*ExternalUploadResponse, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
@@ -18,6 +21,7 @@ func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRe
|
|||||||
if uploaderName == "" {
|
if uploaderName == "" {
|
||||||
return nil, ErrMissingUploaderName
|
return nil, ErrMissingUploaderName
|
||||||
}
|
}
|
||||||
|
sourceChannel := req.normalizedSourceChannel()
|
||||||
items, err := parseExternalUploadItems(req.Data)
|
items, err := parseExternalUploadItems(req.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -45,6 +49,7 @@ func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRe
|
|||||||
parsedPayload, _ := json.Marshal(item)
|
parsedPayload, _ := json.Marshal(item)
|
||||||
dto, err := s.repo.CreateFromExternalUpload(ctx, externalUploadCreate{
|
dto, err := s.repo.CreateFromExternalUpload(ctx, externalUploadCreate{
|
||||||
UploaderName: uploaderName,
|
UploaderName: uploaderName,
|
||||||
|
SourceChannel: sourceChannel,
|
||||||
ClientUploadTime: clientUploadTime,
|
ClientUploadTime: clientUploadTime,
|
||||||
ClientIP: meta.IP,
|
ClientIP: meta.IP,
|
||||||
RawPayload: meta.RawPayload,
|
RawPayload: meta.RawPayload,
|
||||||
@@ -85,6 +90,17 @@ func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRe
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeExternalSourceChannel(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len([]rune(value)) > 32 {
|
||||||
|
return string([]rune(value)[:32])
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func parseExternalUploadItems(raw json.RawMessage) ([]ExternalAccountData, error) {
|
func parseExternalUploadItems(raw json.RawMessage) ([]ExternalAccountData, error) {
|
||||||
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || string(raw) == "null" {
|
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || string(raw) == "null" {
|
||||||
return nil, ErrMissingUploadData
|
return nil, ErrMissingUploadData
|
||||||
@@ -228,6 +244,7 @@ func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item
|
|||||||
loadLevel := levelText(item.DailyConsumption.Weight)
|
loadLevel := levelText(item.DailyConsumption.Weight)
|
||||||
skins := cleanStrings(item.Inventory.Skins)
|
skins := cleanStrings(item.Inventory.Skins)
|
||||||
remark := strings.TrimSpace(item.Remark)
|
remark := strings.TrimSpace(item.Remark)
|
||||||
|
onlineTimeText := normalizeExternalOnlineTimeText(item.OwnerOnlineTime)
|
||||||
assetSummary := map[string]any{
|
assetSummary := map[string]any{
|
||||||
"face_owner": "",
|
"face_owner": "",
|
||||||
"secret_kd": item.SecretKD,
|
"secret_kd": item.SecretKD,
|
||||||
@@ -239,7 +256,7 @@ func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item
|
|||||||
"load_level": loadLevel,
|
"load_level": loadLevel,
|
||||||
"resources": externalResources(item.Inventory),
|
"resources": externalResources(item.Inventory),
|
||||||
"skin_groups": externalSkinGroups(skins),
|
"skin_groups": externalSkinGroups(skins),
|
||||||
"online_time_text": strings.TrimSpace(item.OwnerOnlineTime),
|
"online_time_text": onlineTimeText,
|
||||||
"ban_record": normalizeBanRecord(item.BanRecord),
|
"ban_record": normalizeBanRecord(item.BanRecord),
|
||||||
"common_regions": commonRegions(item.CommonRegion),
|
"common_regions": commonRegions(item.CommonRegion),
|
||||||
"remark": remark,
|
"remark": remark,
|
||||||
@@ -261,6 +278,12 @@ func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item
|
|||||||
"platform_rule_type": "external_upload",
|
"platform_rule_type": "external_upload",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if start, end, ok := parseExternalOnlineTimeRange(onlineTimeText); ok {
|
||||||
|
assetSummary["online_time"] = map[string]any{
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
}
|
||||||
|
}
|
||||||
return CreateRequest{
|
return CreateRequest{
|
||||||
Title: externalUploadTitle(item, insurance, hafCoinM),
|
Title: externalUploadTitle(item, insurance, hafCoinM),
|
||||||
Description: remark,
|
Description: remark,
|
||||||
@@ -275,6 +298,49 @@ func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeExternalOnlineTimeText(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
start, end, ok := parseExternalOnlineTimeRange(value)
|
||||||
|
if !ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return start + " 至 " + end
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExternalOnlineTimeRange(value string) (string, string, bool) {
|
||||||
|
matches := externalOnlineTimePattern.FindStringSubmatch(strings.TrimSpace(value))
|
||||||
|
if len(matches) != 5 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
start, ok := normalizeExternalTimePart(matches[1], matches[2])
|
||||||
|
if !ok {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
end, ok := normalizeExternalTimePart(matches[3], matches[4])
|
||||||
|
if !ok {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return start, end, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeExternalTimePart(hourText, minuteText string) (string, bool) {
|
||||||
|
hour, err := strconv.Atoi(strings.TrimSpace(hourText))
|
||||||
|
if err != nil || hour < 0 || hour > 23 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
minute := 0
|
||||||
|
if strings.TrimSpace(minuteText) != "" {
|
||||||
|
minute, err = strconv.Atoi(strings.TrimSpace(minuteText))
|
||||||
|
if err != nil || minute < 0 || minute > 59 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%02d:%02d", hour, minute), true
|
||||||
|
}
|
||||||
|
|
||||||
func externalUploadTitle(item ExternalAccountData, insurance string, hafCoinM float64) string {
|
func externalUploadTitle(item ExternalAccountData, insurance string, hafCoinM float64) string {
|
||||||
parts := []string{
|
parts := []string{
|
||||||
strings.TrimSpace(item.Rank),
|
strings.TrimSpace(item.Rank),
|
||||||
@@ -402,7 +468,6 @@ func normalizeBanRecord(value string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func commonRegions(value string) []string {
|
func commonRegions(value string) []string {
|
||||||
return cleanStrings(strings.Split(value, ","))
|
return cleanStrings(strings.Split(value, ","))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ func (r *Repository) ensureCreateCooldown(tx *gorm.DB, ownerID uint64, cooldown
|
|||||||
|
|
||||||
type externalUploadCreate struct {
|
type externalUploadCreate struct {
|
||||||
UploaderName string
|
UploaderName string
|
||||||
|
SourceChannel string
|
||||||
ClientUploadTime *time.Time
|
ClientUploadTime *time.Time
|
||||||
ClientIP string
|
ClientIP string
|
||||||
RawPayload []byte
|
RawPayload []byte
|
||||||
@@ -186,6 +187,7 @@ func (r *Repository) CreateFromExternalUpload(ctx context.Context, upload extern
|
|||||||
listingID := listing.ID
|
listingID := listing.ID
|
||||||
uploadRow := model.ListingUpload{
|
uploadRow := model.ListingUpload{
|
||||||
UploaderName: upload.UploaderName,
|
UploaderName: upload.UploaderName,
|
||||||
|
SourceChannel: upload.SourceChannel,
|
||||||
MatchedAdminID: &matchedAdminID,
|
MatchedAdminID: &matchedAdminID,
|
||||||
OwnerID: &ownerID,
|
OwnerID: &ownerID,
|
||||||
ClientUploadTime: upload.ClientUploadTime,
|
ClientUploadTime: upload.ClientUploadTime,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type listingRow struct {
|
|||||||
Title string
|
Title string
|
||||||
OwnerPhone string
|
OwnerPhone string
|
||||||
OwnerNickname string
|
OwnerNickname string
|
||||||
|
SourceChannel string `gorm:"column:source_channel"`
|
||||||
Description string
|
Description string
|
||||||
GameName string
|
GameName string
|
||||||
ServerRegion string
|
ServerRegion string
|
||||||
@@ -63,14 +64,20 @@ func sellerListings(items []ListingDTO) []ListingDTO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyPublicListingURLs(item *ListingDTO) {
|
func applyPublicListingURLs(item *ListingDTO) {
|
||||||
|
item.SourceChannel = ""
|
||||||
item.ScreenshotURLS = publicScreenshotURLs(item.ID, item.ScreenshotURLS, item.Status, item.ReviewStatus)
|
item.ScreenshotURLS = publicScreenshotURLs(item.ID, item.ScreenshotURLS, item.Status, item.ReviewStatus)
|
||||||
if item.AssetSummary != nil {
|
if item.AssetSummary != nil {
|
||||||
|
delete(item.AssetSummary, "import_meta")
|
||||||
delete(item.AssetSummary, "price_breakdown")
|
delete(item.AssetSummary, "price_breakdown")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applySellerListingPrice(item *ListingDTO) {
|
func applySellerListingPrice(item *ListingDTO) {
|
||||||
if item == nil || item.AssetSummary == nil {
|
if item == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.SourceChannel = ""
|
||||||
|
if item.AssetSummary == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
breakdown, ok := item.AssetSummary["price_breakdown"].(map[string]any)
|
breakdown, ok := item.AssetSummary["price_breakdown"].(map[string]any)
|
||||||
@@ -103,6 +110,7 @@ func (row listingRow) toDTO() ListingDTO {
|
|||||||
OwnerID: row.OwnerID,
|
OwnerID: row.OwnerID,
|
||||||
OwnerPhone: row.OwnerPhone,
|
OwnerPhone: row.OwnerPhone,
|
||||||
OwnerNickname: row.OwnerNickname,
|
OwnerNickname: row.OwnerNickname,
|
||||||
|
SourceChannel: row.SourceChannel,
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
Description: row.Description,
|
Description: row.Description,
|
||||||
GameName: row.GameName,
|
GameName: row.GameName,
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import (
|
|||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sourceChannelWebsite = "站内发布"
|
||||||
|
sourceChannelExternalUnknown = "未填写"
|
||||||
|
)
|
||||||
|
|
||||||
func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*AdminListResult, error) {
|
func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*AdminListResult, error) {
|
||||||
page := query.Page
|
page := query.Page
|
||||||
if page <= 0 {
|
if page <= 0 {
|
||||||
@@ -199,11 +204,21 @@ func (r *Repository) findDTO(ctx context.Context, where string, args ...any) (*L
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
||||||
|
uploadSource := r.db.WithContext(ctx).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")
|
||||||
return r.db.WithContext(ctx).Table("rental_listings AS l").
|
return r.db.WithContext(ctx).Table("rental_listings AS l").
|
||||||
Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level,
|
Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level,
|
||||||
a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname`).
|
a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname,
|
||||||
|
CASE
|
||||||
|
WHEN lu.upload_id IS NULL THEN ?
|
||||||
|
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
|
||||||
|
ELSE lu.source_channel
|
||||||
|
END AS source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown).
|
||||||
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
||||||
Joins("LEFT JOIN users AS u ON u.id = l.owner_id")
|
Joins("LEFT JOIN users AS u ON u.id = l.owner_id").
|
||||||
|
Joins("LEFT JOIN (?) AS lu ON lu.listing_id = l.id", uploadSource)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) {
|
func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) {
|
||||||
|
|||||||
@@ -319,6 +319,34 @@ func TestApplySellerListingPriceKeepsFallbackPrice(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) {
|
||||||
|
item := &ListingDTO{
|
||||||
|
ID: 1,
|
||||||
|
Status: "published",
|
||||||
|
ReviewStatus: "approved",
|
||||||
|
SourceChannel: "淘宝",
|
||||||
|
ScreenshotURLS: []string{
|
||||||
|
"https://example.com/account.png",
|
||||||
|
},
|
||||||
|
AssetSummary: map[string]any{
|
||||||
|
"import_meta": map[string]any{"uploader_name": "客服1"},
|
||||||
|
"price_breakdown": map[string]any{"buyer_total_price": 100},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPublicListingURLs(item)
|
||||||
|
|
||||||
|
if item.SourceChannel != "" {
|
||||||
|
t.Fatalf("expected public listing source channel hidden, got %q", item.SourceChannel)
|
||||||
|
}
|
||||||
|
if _, ok := item.AssetSummary["import_meta"]; ok {
|
||||||
|
t.Fatal("expected import_meta hidden from public listing")
|
||||||
|
}
|
||||||
|
if _, ok := item.AssetSummary["price_breakdown"]; ok {
|
||||||
|
t.Fatal("expected price_breakdown hidden from public listing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseExternalUploadItemsAcceptsSingleObject(t *testing.T) {
|
func TestParseExternalUploadItemsAcceptsSingleObject(t *testing.T) {
|
||||||
items, err := parseExternalUploadItems(json.RawMessage(validExternalUploadDataJSON()))
|
items, err := parseExternalUploadItems(json.RawMessage(validExternalUploadDataJSON()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -346,18 +374,118 @@ func TestParseExternalUploadItemsReportsMissingFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImportExternalUploadStoresSourceChannel(t *testing.T) {
|
||||||
|
db := database.NewTestDB()
|
||||||
|
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
|
||||||
|
t.Fatalf("failed to migrate test db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.AdminUser{}, &model.ListingUpload{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate upload tables: %v", err)
|
||||||
|
}
|
||||||
|
admin := model.AdminUser{
|
||||||
|
Username: "客服1",
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Nickname: "客服1",
|
||||||
|
Status: "active",
|
||||||
|
}
|
||||||
|
if err := db.Create(&admin).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create admin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
service := NewService(NewRepository(db, nil), nil)
|
||||||
|
resp, err := service.ImportExternalUpload(t.Context(), ExternalUploadRequest{
|
||||||
|
UploaderName: "客服1",
|
||||||
|
SourceChannel: "淘宝",
|
||||||
|
Data: json.RawMessage(validExternalUploadDataJSON()),
|
||||||
|
}, ExternalUploadMeta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ImportExternalUpload() error = %v", err)
|
||||||
|
}
|
||||||
|
if resp.Success != 1 || resp.ListingID == 0 {
|
||||||
|
t.Fatalf("unexpected response: %#v", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var upload model.ListingUpload
|
||||||
|
if err := db.Where("listing_id = ?", resp.ListingID).First(&upload).Error; err != nil {
|
||||||
|
t.Fatalf("failed to find listing upload: %v", err)
|
||||||
|
}
|
||||||
|
if upload.SourceChannel != "淘宝" {
|
||||||
|
t.Fatalf("source channel = %q, want 淘宝", upload.SourceChannel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPendingReviewIncludesSourceChannel(t *testing.T) {
|
||||||
|
db := database.NewTestDB()
|
||||||
|
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
|
||||||
|
t.Fatalf("failed to migrate test db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.ListingUpload{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate listing uploads: %v", err)
|
||||||
|
}
|
||||||
|
user := model.User{Phone: "18800000001", RealnameStatus: "verified", Status: "active"}
|
||||||
|
if err := db.Create(&user).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create user: %v", err)
|
||||||
|
}
|
||||||
|
account := model.GameAccount{
|
||||||
|
OwnerID: user.ID,
|
||||||
|
GameName: "delta_force",
|
||||||
|
ServerRegion: "QQ",
|
||||||
|
LoginPlatform: "QQ账号密码",
|
||||||
|
Title: "待审核账号",
|
||||||
|
RankLevel: "黑鹰",
|
||||||
|
Status: "draft",
|
||||||
|
}
|
||||||
|
if err := db.Create(&account).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create account: %v", err)
|
||||||
|
}
|
||||||
|
listing := model.RentalListing{
|
||||||
|
ListingNo: "202607250001",
|
||||||
|
AccountID: account.ID,
|
||||||
|
OwnerID: user.ID,
|
||||||
|
PriceCent: 10000,
|
||||||
|
DepositAmountCent: 50000,
|
||||||
|
Status: "draft",
|
||||||
|
ReviewStatus: "pending",
|
||||||
|
}
|
||||||
|
if err := db.Create(&listing).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create listing: %v", err)
|
||||||
|
}
|
||||||
|
listingID := listing.ID
|
||||||
|
if err := db.Create(&model.ListingUpload{
|
||||||
|
UploaderName: "客服1",
|
||||||
|
SourceChannel: "淘宝",
|
||||||
|
OwnerID: &user.ID,
|
||||||
|
ListingID: &listingID,
|
||||||
|
Status: "draft_created",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create listing upload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := NewRepository(db, nil).ListPendingReview(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPendingReview() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("expected 1 pending listing, got %d", len(items))
|
||||||
|
}
|
||||||
|
if items[0].SourceChannel != "淘宝" {
|
||||||
|
t.Fatalf("source channel = %q, want 淘宝", items[0].SourceChannel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
|
func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
|
||||||
req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{
|
req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{
|
||||||
LoginMethod: "QQ账号密码",
|
LoginMethod: "QQ账号密码",
|
||||||
Rank: "黑鹰",
|
Rank: "黑鹰",
|
||||||
Level: 60,
|
Level: 60,
|
||||||
SafeSlots: 9,
|
SafeSlots: 9,
|
||||||
SecretKD: 1.6,
|
SecretKD: 1.6,
|
||||||
DailyLossM: 7,
|
DailyLossM: 7,
|
||||||
Deposit: 400,
|
Deposit: 400,
|
||||||
BanRecord: "有",
|
BanRecord: "有",
|
||||||
CommonRegion: "郑州",
|
CommonRegion: "郑州",
|
||||||
Remark: "有语音封禁\n封禁历史:2026/01/23封禁3天",
|
OwnerOnlineTime: "8:00-1:00",
|
||||||
|
Remark: "有语音封禁\n封禁历史:2026/01/23封禁3天",
|
||||||
Currency: ExternalUploadCurrency{
|
Currency: ExternalUploadCurrency{
|
||||||
HafuCoin: 197.1,
|
HafuCoin: 197.1,
|
||||||
RecycleRatio: 43,
|
RecycleRatio: 43,
|
||||||
@@ -399,6 +527,16 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
|
|||||||
if req.AssetSummary["stamina_level"] != "7级" || req.AssetSummary["load_level"] != "7级" {
|
if req.AssetSummary["stamina_level"] != "7级" || req.AssetSummary["load_level"] != "7级" {
|
||||||
t.Fatalf("unexpected stamina/load: %#v", req.AssetSummary)
|
t.Fatalf("unexpected stamina/load: %#v", req.AssetSummary)
|
||||||
}
|
}
|
||||||
|
if req.AssetSummary["online_time_text"] != "08:00 至 01:00" {
|
||||||
|
t.Fatalf("online_time_text = %#v", req.AssetSummary["online_time_text"])
|
||||||
|
}
|
||||||
|
onlineTime, ok := req.AssetSummary["online_time"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("online_time missing: %#v", req.AssetSummary["online_time"])
|
||||||
|
}
|
||||||
|
if onlineTime["start"] != "08:00" || onlineTime["end"] != "01:00" {
|
||||||
|
t.Fatalf("online_time = %#v", onlineTime)
|
||||||
|
}
|
||||||
groups := req.AssetSummary["skin_groups"].(map[string][]string)
|
groups := req.AssetSummary["skin_groups"].(map[string][]string)
|
||||||
if len(groups["melee"]) != 2 {
|
if len(groups["melee"]) != 2 {
|
||||||
t.Fatalf("expected melee skins mapped, got %#v", groups)
|
t.Fatalf("expected melee skins mapped, got %#v", groups)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
ALTER TABLE listing_uploads
|
||||||
|
ADD COLUMN source_channel VARCHAR(32) NOT NULL DEFAULT '' COMMENT '订单来源渠道:咸鱼/淘宝/京东/QQ/微信/自定义' AFTER uploader_name,
|
||||||
|
ADD KEY idx_listing_uploads_source_channel (source_channel, created_at);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
ALTER TABLE listing_uploads
|
||||||
|
DROP KEY idx_listing_uploads_source_channel,
|
||||||
|
DROP COLUMN source_channel;
|
||||||
@@ -27,9 +27,15 @@ export interface ListingDayStats {
|
|||||||
trade_leave_count: number
|
trade_leave_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListingChannelDayStats extends ListingDayStats {
|
||||||
|
source_channel: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ListingDailyOverview {
|
export interface ListingDailyOverview {
|
||||||
today: ListingDayStats
|
today: ListingDayStats
|
||||||
trend: ListingDayStats[]
|
trend: ListingDayStats[]
|
||||||
|
today_channels: ListingChannelDayStats[]
|
||||||
|
channel_trend: ListingChannelDayStats[]
|
||||||
days: number
|
days: number
|
||||||
timezone: string
|
timezone: string
|
||||||
}
|
}
|
||||||
@@ -82,6 +88,8 @@ export async function fetchAdminDashboard() {
|
|||||||
listing_daily: {
|
listing_daily: {
|
||||||
today: listingDaily?.today ?? emptyDayStats(),
|
today: listingDaily?.today ?? emptyDayStats(),
|
||||||
trend: listingDaily?.trend ?? [],
|
trend: listingDaily?.trend ?? [],
|
||||||
|
today_channels: listingDaily?.today_channels ?? [],
|
||||||
|
channel_trend: listingDaily?.channel_trend ?? [],
|
||||||
days: listingDaily?.days ?? 7,
|
days: listingDaily?.days ?? 7,
|
||||||
timezone: listingDaily?.timezone ?? 'Asia/Shanghai',
|
timezone: listingDaily?.timezone ?? 'Asia/Shanghai',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
fetchAdminDashboard,
|
fetchAdminDashboard,
|
||||||
type AdminDashboard,
|
type AdminDashboard,
|
||||||
|
type ListingChannelDayStats,
|
||||||
type ListingDayStats,
|
type ListingDayStats,
|
||||||
} from '@/features/admin/api/adminDashboard'
|
} from '@/features/admin/api/adminDashboard'
|
||||||
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
|
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
|
||||||
@@ -40,6 +41,7 @@ const {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const listingTrend = computed(() => dashboard.value?.listing_daily?.trend ?? [])
|
const listingTrend = computed(() => dashboard.value?.listing_daily?.trend ?? [])
|
||||||
|
const listingChannelTrend = computed(() => dashboard.value?.listing_daily?.channel_trend ?? [])
|
||||||
const listingToday = computed(
|
const listingToday = computed(
|
||||||
() =>
|
() =>
|
||||||
dashboard.value?.listing_daily?.today ?? {
|
dashboard.value?.listing_daily?.today ?? {
|
||||||
@@ -49,6 +51,25 @@ const listingToday = computed(
|
|||||||
trade_leave_count: 0,
|
trade_leave_count: 0,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
const channelDisplayOrder = ['咸鱼', '淘宝', '京东', 'QQ', '微信', '自定义', '站内发布', '未填写']
|
||||||
|
const listingTodayChannels = computed<ListingChannelDayStats[]>(() => {
|
||||||
|
const date = listingToday.value.date
|
||||||
|
const rows = dashboard.value?.listing_daily?.today_channels ?? []
|
||||||
|
const byChannel = new Map(rows.map(row => [row.source_channel || '未填写', row]))
|
||||||
|
const ordered = channelDisplayOrder.map(channel => ({
|
||||||
|
date,
|
||||||
|
source_channel: channel,
|
||||||
|
published_count: byChannel.get(channel)?.published_count ?? 0,
|
||||||
|
active_offline_count: byChannel.get(channel)?.active_offline_count ?? 0,
|
||||||
|
trade_leave_count: byChannel.get(channel)?.trade_leave_count ?? 0,
|
||||||
|
}))
|
||||||
|
const extras = rows.filter(row => !channelDisplayOrder.includes(row.source_channel || '未填写'))
|
||||||
|
return [...ordered, ...extras]
|
||||||
|
})
|
||||||
|
|
||||||
|
function leaveCount(row: ListingChannelDayStats | ListingDayStats) {
|
||||||
|
return Number(row.active_offline_count || 0) + Number(row.trade_leave_count || 0)
|
||||||
|
}
|
||||||
|
|
||||||
function trendMax(getter: (row: ListingDayStats) => number) {
|
function trendMax(getter: (row: ListingDayStats) => number) {
|
||||||
const values = listingTrend.value.map(getter)
|
const values = listingTrend.value.map(getter)
|
||||||
@@ -248,6 +269,24 @@ function shortDate(date: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="listing-channel-summary">
|
||||||
|
<div
|
||||||
|
v-for="row in listingTodayChannels"
|
||||||
|
:key="`today-channel-${row.source_channel}`"
|
||||||
|
class="listing-channel-item"
|
||||||
|
>
|
||||||
|
<div class="listing-channel-item-head">
|
||||||
|
<span>{{ row.source_channel || '未填写' }}</span>
|
||||||
|
<strong>{{ row.published_count + leaveCount(row) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="listing-channel-counts">
|
||||||
|
<span>上架 {{ row.published_count }}</span>
|
||||||
|
<span>主动下架 {{ row.active_offline_count }}</span>
|
||||||
|
<span>交易离架 {{ row.trade_leave_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-table class="table-panel listing-trend-table" :data="listingTrend" size="small">
|
<el-table class="table-panel listing-trend-table" :data="listingTrend" size="small">
|
||||||
<el-table-column prop="date" label="日期" min-width="120" />
|
<el-table-column prop="date" label="日期" min-width="120" />
|
||||||
<el-table-column prop="published_count" label="上架" width="100" align="center" />
|
<el-table-column prop="published_count" label="上架" width="100" align="center" />
|
||||||
@@ -259,6 +298,35 @@ function shortDate(date: string) {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
<div v-if="listingChannelTrend.length" class="listing-channel-block">
|
||||||
|
<div class="listing-channel-title">近 7 日渠道明细</div>
|
||||||
|
<el-table
|
||||||
|
class="table-panel listing-channel-table"
|
||||||
|
:data="listingChannelTrend"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<el-table-column prop="date" label="日期" min-width="120" />
|
||||||
|
<el-table-column prop="source_channel" label="渠道" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="listing-channel-name">{{ row.source_channel || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="published_count" label="上架" width="100" align="center" />
|
||||||
|
<el-table-column
|
||||||
|
prop="active_offline_count"
|
||||||
|
label="主动下架"
|
||||||
|
width="110"
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
|
<el-table-column prop="trade_leave_count" label="交易离架" width="110" align="center" />
|
||||||
|
<el-table-column label="合计离架" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ leaveCount(row) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 待处理事项 & 快捷入口 -->
|
<!-- 待处理事项 & 快捷入口 -->
|
||||||
@@ -789,10 +857,80 @@ function shortDate(date: string) {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.listing-channel-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-channel-item {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #e8edf4;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-channel-item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-channel-item-head span {
|
||||||
|
min-width: 0;
|
||||||
|
color: #1b2559;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-channel-item-head strong {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #4f7cff;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-channel-counts {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-channel-counts span {
|
||||||
|
color: #8f9bba;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-channel-block {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-channel-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1b2559;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listing-channel-name {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1b2559;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.listing-daily-grid {
|
.listing-daily-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.listing-channel-summary {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -802,6 +940,10 @@ function shortDate(date: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
.listing-channel-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.metric-card {
|
.metric-card {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -137,7 +137,12 @@ function selectListing(row: Listing) {
|
|||||||
function replaceListing(next: Listing) {
|
function replaceListing(next: Listing) {
|
||||||
const index = listings.value.findIndex(item => item.id === next.id)
|
const index = listings.value.findIndex(item => item.id === next.id)
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
listings.value.splice(index, 1, next)
|
const previous = listings.value[index]!
|
||||||
|
listings.value.splice(index, 1, {
|
||||||
|
...previous,
|
||||||
|
...next,
|
||||||
|
source_channel: next.source_channel || previous.source_channel,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
listings.value.unshift(next)
|
listings.value.unshift(next)
|
||||||
}
|
}
|
||||||
@@ -422,6 +427,13 @@ function contactPhone(row: Listing) {
|
|||||||
return typeof value === 'string' && value.trim() ? value : '-'
|
return typeof value === 'string' && value.trim() ? value : '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sourceChannel(row: Listing) {
|
||||||
|
if (typeof row.source_channel === 'string' && row.source_channel.trim()) {
|
||||||
|
return row.source_channel.trim()
|
||||||
|
}
|
||||||
|
return isExternalUpload(row) ? '未填写' : '站内发布'
|
||||||
|
}
|
||||||
|
|
||||||
function ownerOnlineText(row: Listing) {
|
function ownerOnlineText(row: Listing) {
|
||||||
const text = row.asset_summary?.online_time_text
|
const text = row.asset_summary?.online_time_text
|
||||||
if (typeof text === 'string' && text.trim()) return text
|
if (typeof text === 'string' && text.trim()) return text
|
||||||
@@ -456,7 +468,15 @@ function isDefaultScreenshot(url: string) {
|
|||||||
|
|
||||||
function riskItems(row: Listing): RiskItem[] {
|
function riskItems(row: Listing): RiskItem[] {
|
||||||
const items: RiskItem[] = []
|
const items: RiskItem[] = []
|
||||||
if (isExternalUpload(row)) items.push({ label: `外部上传:${uploaderName(row)}`, level: 'info' })
|
if (isExternalUpload(row)) {
|
||||||
|
items.push({
|
||||||
|
label: `外部上传:${uploaderName(row)} / 渠道:${sourceChannel(row)}`,
|
||||||
|
level: 'info',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
items.push({ label: `来源渠道:${sourceChannel(row)}`, level: 'info' })
|
||||||
|
}
|
||||||
|
const infoCount = items.length
|
||||||
if (!row.screenshot_urls?.length) items.push({ label: '没有账号截图', level: 'danger' })
|
if (!row.screenshot_urls?.length) items.push({ label: '没有账号截图', level: 'danger' })
|
||||||
if (hasDefaultScreenshot(row)) items.push({ label: '使用默认截图', level: 'warning' })
|
if (hasDefaultScreenshot(row)) items.push({ label: '使用默认截图', level: 'warning' })
|
||||||
if (hasBanRecord(row)) items.push({ label: `封禁记录:${banRecordText(row)}`, level: 'danger' })
|
if (hasBanRecord(row)) items.push({ label: `封禁记录:${banRecordText(row)}`, level: 'danger' })
|
||||||
@@ -472,7 +492,7 @@ function riskItems(row: Listing): RiskItem[] {
|
|||||||
items.push({ label: '烽火等级接近下限', level: 'warning' })
|
items.push({ label: '烽火等级接近下限', level: 'warning' })
|
||||||
if (!readAssetString(row, 'season_insurance'))
|
if (!readAssetString(row, 'season_insurance'))
|
||||||
items.push({ label: '缺少保险格数', level: 'warning' })
|
items.push({ label: '缺少保险格数', level: 'warning' })
|
||||||
if (!items.length) items.push({ label: '未发现明显风险', level: 'info' })
|
if (items.length === infoCount) items.push({ label: '未发现明显风险', level: 'info' })
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +515,7 @@ function reviewSearchText(row: Listing) {
|
|||||||
row.login_platform,
|
row.login_platform,
|
||||||
row.rank_level,
|
row.rank_level,
|
||||||
uploaderName(row),
|
uploaderName(row),
|
||||||
|
sourceChannel(row),
|
||||||
getSkinNames(row).join(' '),
|
getSkinNames(row).join(' '),
|
||||||
]
|
]
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -602,7 +623,7 @@ async function openScreenshot(url: string) {
|
|||||||
v-model="filters.keyword"
|
v-model="filters.keyword"
|
||||||
:prefix-icon="Search"
|
:prefix-icon="Search"
|
||||||
clearable
|
clearable
|
||||||
placeholder="搜索编号、标题、客服、段位、皮肤"
|
placeholder="搜索编号、标题、客服、渠道、段位、皮肤"
|
||||||
/>
|
/>
|
||||||
<el-select v-model="filters.risk" placeholder="风险筛选">
|
<el-select v-model="filters.risk" placeholder="风险筛选">
|
||||||
<el-option label="全部待审" value="all" />
|
<el-option label="全部待审" value="all" />
|
||||||
@@ -642,10 +663,17 @@ async function openScreenshot(url: string) {
|
|||||||
formatCent(item.deposit_amount_cent)
|
formatCent(item.deposit_amount_cent)
|
||||||
}}</span
|
}}</span
|
||||||
>
|
>
|
||||||
<el-tag v-if="isExternalUpload(item)" size="small" type="info">{{
|
<div class="queue-tags">
|
||||||
uploaderName(item)
|
<el-tag size="small" type="primary" effect="plain">{{
|
||||||
}}</el-tag>
|
sourceChannel(item)
|
||||||
<el-tag v-if="hasDefaultScreenshot(item)" size="small" type="warning">默认图</el-tag>
|
}}</el-tag>
|
||||||
|
<el-tag v-if="isExternalUpload(item)" size="small" type="info">{{
|
||||||
|
uploaderName(item)
|
||||||
|
}}</el-tag>
|
||||||
|
<el-tag v-if="hasDefaultScreenshot(item)" size="small" type="warning"
|
||||||
|
>默认图</el-tag
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<el-empty
|
<el-empty
|
||||||
@@ -782,11 +810,20 @@ async function openScreenshot(url: string) {
|
|||||||
<div class="review-panel">
|
<div class="review-panel">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<h3>上传信息</h3>
|
<h3>上传信息</h3>
|
||||||
<el-tag v-if="isExternalUpload(selectedListing)" size="small" type="info"
|
<div class="panel-tags">
|
||||||
>外部上传</el-tag
|
<el-tag size="small" type="primary" effect="plain">{{
|
||||||
>
|
sourceChannel(selectedListing)
|
||||||
|
}}</el-tag>
|
||||||
|
<el-tag v-if="isExternalUpload(selectedListing)" size="small" type="info"
|
||||||
|
>外部上传</el-tag
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl class="detail-list">
|
<dl class="detail-list">
|
||||||
|
<div>
|
||||||
|
<dt>来源渠道</dt>
|
||||||
|
<dd>{{ sourceChannel(selectedListing) }}</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>上传人</dt>
|
<dt>上传人</dt>
|
||||||
<dd>{{ uploaderName(selectedListing) }}</dd>
|
<dd>{{ uploaderName(selectedListing) }}</dd>
|
||||||
@@ -1206,6 +1243,14 @@ async function openScreenshot(url: string) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queue-tags,
|
||||||
|
.panel-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.review-detail {
|
.review-detail {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface Listing {
|
|||||||
owner_id: number
|
owner_id: number
|
||||||
owner_phone?: string
|
owner_phone?: string
|
||||||
owner_nickname?: string
|
owner_nickname?: string
|
||||||
|
source_channel?: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
game_name: string
|
game_name: string
|
||||||
|
|||||||
Reference in New Issue
Block a user