diff --git a/backend/internal/model/listing.go b/backend/internal/model/listing.go index 0b5a5cc..7b32deb 100644 --- a/backend/internal/model/listing.go +++ b/backend/internal/model/listing.go @@ -51,6 +51,7 @@ func (RentalListing) TableName() string { type ListingUpload struct { ID uint64 `gorm:"primaryKey" json:"id"` 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"` OwnerID *uint64 `gorm:"index" json:"owner_id"` ClientUploadTime *time.Time `json:"client_upload_time"` diff --git a/backend/internal/modules/admindashboard/dto.go b/backend/internal/modules/admindashboard/dto.go index c886713..2bf981d 100644 --- a/backend/internal/modules/admindashboard/dto.go +++ b/backend/internal/modules/admindashboard/dto.go @@ -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 { diff --git a/backend/internal/modules/admindashboard/repository.go b/backend/internal/modules/admindashboard/repository.go index bdf0930..1623e76 100644 --- a/backend/internal/modules/admindashboard/repository.go +++ b/backend/internal/modules/admindashboard/repository.go @@ -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) diff --git a/backend/internal/modules/admindashboard/repository_test.go b/backend/internal/modules/admindashboard/repository_test.go new file mode 100644 index 0000000..5ae4f82 --- /dev/null +++ b/backend/internal/modules/admindashboard/repository_test.go @@ -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) + } +} diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 9c7d591..023668f 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -15,6 +15,7 @@ type ListingDTO struct { OwnerID uint64 `json:"owner_id"` OwnerPhone string `json:"owner_phone,omitempty"` OwnerNickname string `json:"owner_nickname,omitempty"` + SourceChannel string `json:"source_channel,omitempty"` Title string `json:"title"` Description string `json:"description"` GameName string `json:"game_name"` @@ -149,10 +150,12 @@ type AdminActionRequest struct { type AuditMeta = auditlog.Meta type ExternalUploadRequest struct { - UploadTime int64 `json:"uploadTime"` - UploaderName string `json:"uploaderName"` - Uploaderame string `json:"uploaderame"` - Data json.RawMessage `json:"data"` + UploadTime int64 `json:"uploadTime"` + UploaderName string `json:"uploaderName"` + Uploaderame string `json:"uploaderame"` + SourceChannel string `json:"sourceChannel"` + SourceChannelSnake string `json:"source_channel"` + Data json.RawMessage `json:"data"` } func (r ExternalUploadRequest) normalizedUploaderName() string { @@ -162,6 +165,13 @@ func (r ExternalUploadRequest) normalizedUploaderName() string { 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 { LoginMethod string `json:"loginMethod"` Rank string `json:"rank"` diff --git a/backend/internal/modules/listing/external_upload.go b/backend/internal/modules/listing/external_upload.go index a814d6f..fd0a232 100644 --- a/backend/internal/modules/listing/external_upload.go +++ b/backend/internal/modules/listing/external_upload.go @@ -5,11 +5,14 @@ import ( "encoding/json" "fmt" "math" + "regexp" "strconv" "strings" "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) { if s.repo == nil { return nil, ErrDependencyUnavailable @@ -18,6 +21,7 @@ func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRe if uploaderName == "" { return nil, ErrMissingUploaderName } + sourceChannel := req.normalizedSourceChannel() items, err := parseExternalUploadItems(req.Data) if err != nil { return nil, err @@ -45,6 +49,7 @@ func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRe parsedPayload, _ := json.Marshal(item) dto, err := s.repo.CreateFromExternalUpload(ctx, externalUploadCreate{ UploaderName: uploaderName, + SourceChannel: sourceChannel, ClientUploadTime: clientUploadTime, ClientIP: meta.IP, RawPayload: meta.RawPayload, @@ -85,6 +90,17 @@ func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRe 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) { if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || string(raw) == "null" { return nil, ErrMissingUploadData @@ -228,6 +244,7 @@ func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item loadLevel := levelText(item.DailyConsumption.Weight) skins := cleanStrings(item.Inventory.Skins) remark := strings.TrimSpace(item.Remark) + onlineTimeText := normalizeExternalOnlineTimeText(item.OwnerOnlineTime) assetSummary := map[string]any{ "face_owner": "", "secret_kd": item.SecretKD, @@ -239,7 +256,7 @@ func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item "load_level": loadLevel, "resources": externalResources(item.Inventory), "skin_groups": externalSkinGroups(skins), - "online_time_text": strings.TrimSpace(item.OwnerOnlineTime), + "online_time_text": onlineTimeText, "ban_record": normalizeBanRecord(item.BanRecord), "common_regions": commonRegions(item.CommonRegion), "remark": remark, @@ -261,6 +278,12 @@ func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item "platform_rule_type": "external_upload", }, } + if start, end, ok := parseExternalOnlineTimeRange(onlineTimeText); ok { + assetSummary["online_time"] = map[string]any{ + "start": start, + "end": end, + } + } return CreateRequest{ Title: externalUploadTitle(item, insurance, hafCoinM), 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 { parts := []string{ strings.TrimSpace(item.Rank), @@ -402,7 +468,6 @@ func normalizeBanRecord(value string) string { } } - func commonRegions(value string) []string { return cleanStrings(strings.Split(value, ",")) } diff --git a/backend/internal/modules/listing/mutation.go b/backend/internal/modules/listing/mutation.go index da9c65b..a06728d 100644 --- a/backend/internal/modules/listing/mutation.go +++ b/backend/internal/modules/listing/mutation.go @@ -115,6 +115,7 @@ func (r *Repository) ensureCreateCooldown(tx *gorm.DB, ownerID uint64, cooldown type externalUploadCreate struct { UploaderName string + SourceChannel string ClientUploadTime *time.Time ClientIP string RawPayload []byte @@ -186,6 +187,7 @@ func (r *Repository) CreateFromExternalUpload(ctx context.Context, upload extern listingID := listing.ID uploadRow := model.ListingUpload{ UploaderName: upload.UploaderName, + SourceChannel: upload.SourceChannel, MatchedAdminID: &matchedAdminID, OwnerID: &ownerID, ClientUploadTime: upload.ClientUploadTime, diff --git a/backend/internal/modules/listing/presenter.go b/backend/internal/modules/listing/presenter.go index aa6efe4..04b0e21 100644 --- a/backend/internal/modules/listing/presenter.go +++ b/backend/internal/modules/listing/presenter.go @@ -18,6 +18,7 @@ type listingRow struct { Title string OwnerPhone string OwnerNickname string + SourceChannel string `gorm:"column:source_channel"` Description string GameName string ServerRegion string @@ -63,14 +64,20 @@ func sellerListings(items []ListingDTO) []ListingDTO { } func applyPublicListingURLs(item *ListingDTO) { + item.SourceChannel = "" item.ScreenshotURLS = publicScreenshotURLs(item.ID, item.ScreenshotURLS, item.Status, item.ReviewStatus) if item.AssetSummary != nil { + delete(item.AssetSummary, "import_meta") delete(item.AssetSummary, "price_breakdown") } } func applySellerListingPrice(item *ListingDTO) { - if item == nil || item.AssetSummary == nil { + if item == nil { + return + } + item.SourceChannel = "" + if item.AssetSummary == nil { return } breakdown, ok := item.AssetSummary["price_breakdown"].(map[string]any) @@ -103,6 +110,7 @@ func (row listingRow) toDTO() ListingDTO { OwnerID: row.OwnerID, OwnerPhone: row.OwnerPhone, OwnerNickname: row.OwnerNickname, + SourceChannel: row.SourceChannel, Title: row.Title, Description: row.Description, GameName: row.GameName, diff --git a/backend/internal/modules/listing/query.go b/backend/internal/modules/listing/query.go index a486844..626786e 100644 --- a/backend/internal/modules/listing/query.go +++ b/backend/internal/modules/listing/query.go @@ -14,6 +14,11 @@ import ( "gorm.io/gorm/clause" ) +const ( + sourceChannelWebsite = "站内发布" + sourceChannelExternalUnknown = "未填写" +) + func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*AdminListResult, error) { page := query.Page 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 { + 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"). 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("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) { diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index 20b7118..370d890 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -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) { items, err := parseExternalUploadItems(json.RawMessage(validExternalUploadDataJSON())) 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) { req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{ - LoginMethod: "QQ账号密码", - Rank: "黑鹰", - Level: 60, - SafeSlots: 9, - SecretKD: 1.6, - DailyLossM: 7, - Deposit: 400, - BanRecord: "有", - CommonRegion: "郑州", - Remark: "有语音封禁\n封禁历史:2026/01/23封禁3天", + LoginMethod: "QQ账号密码", + Rank: "黑鹰", + Level: 60, + SafeSlots: 9, + SecretKD: 1.6, + DailyLossM: 7, + Deposit: 400, + BanRecord: "有", + CommonRegion: "郑州", + OwnerOnlineTime: "8:00-1:00", + Remark: "有语音封禁\n封禁历史:2026/01/23封禁3天", Currency: ExternalUploadCurrency{ HafuCoin: 197.1, RecycleRatio: 43, @@ -399,6 +527,16 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) { if req.AssetSummary["stamina_level"] != "7级" || req.AssetSummary["load_level"] != "7级" { 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) if len(groups["melee"]) != 2 { t.Fatalf("expected melee skins mapped, got %#v", groups) diff --git a/backend/migrations/000034_listing_upload_source_channel.sql b/backend/migrations/000034_listing_upload_source_channel.sql new file mode 100644 index 0000000..02c33fe --- /dev/null +++ b/backend/migrations/000034_listing_upload_source_channel.sql @@ -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; diff --git a/frontend/src/features/admin/api/adminDashboard.ts b/frontend/src/features/admin/api/adminDashboard.ts index 9cda77b..862348d 100644 --- a/frontend/src/features/admin/api/adminDashboard.ts +++ b/frontend/src/features/admin/api/adminDashboard.ts @@ -27,9 +27,15 @@ export interface ListingDayStats { trade_leave_count: number } +export interface ListingChannelDayStats extends ListingDayStats { + source_channel: string +} + export interface ListingDailyOverview { today: ListingDayStats trend: ListingDayStats[] + today_channels: ListingChannelDayStats[] + channel_trend: ListingChannelDayStats[] days: number timezone: string } @@ -82,6 +88,8 @@ export async function fetchAdminDashboard() { listing_daily: { today: listingDaily?.today ?? emptyDayStats(), trend: listingDaily?.trend ?? [], + today_channels: listingDaily?.today_channels ?? [], + channel_trend: listingDaily?.channel_trend ?? [], days: listingDaily?.days ?? 7, timezone: listingDaily?.timezone ?? 'Asia/Shanghai', }, diff --git a/frontend/src/features/admin/views/AdminDashboardView.vue b/frontend/src/features/admin/views/AdminDashboardView.vue index ac6093a..f175b82 100644 --- a/frontend/src/features/admin/views/AdminDashboardView.vue +++ b/frontend/src/features/admin/views/AdminDashboardView.vue @@ -22,6 +22,7 @@ import { import { fetchAdminDashboard, type AdminDashboard, + type ListingChannelDayStats, type ListingDayStats, } from '@/features/admin/api/adminDashboard' import { useAdminTable } from '@/features/admin/composables/useAdminTable' @@ -40,6 +41,7 @@ const { }) const listingTrend = computed(() => dashboard.value?.listing_daily?.trend ?? []) +const listingChannelTrend = computed(() => dashboard.value?.listing_daily?.channel_trend ?? []) const listingToday = computed( () => dashboard.value?.listing_daily?.today ?? { @@ -49,6 +51,25 @@ const listingToday = computed( trade_leave_count: 0, } ) +const channelDisplayOrder = ['咸鱼', '淘宝', '京东', 'QQ', '微信', '自定义', '站内发布', '未填写'] +const listingTodayChannels = computed(() => { + 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) { const values = listingTrend.value.map(getter) @@ -248,6 +269,24 @@ function shortDate(date: string) { +
+
+
+ {{ row.source_channel || '未填写' }} + {{ row.published_count + leaveCount(row) }} +
+
+ 上架 {{ row.published_count }} + 主动下架 {{ row.active_offline_count }} + 交易离架 {{ row.trade_leave_count }} +
+
+
+ @@ -259,6 +298,35 @@ function shortDate(date: string) { + +
+
近 7 日渠道明细
+ + + + + + + + + + + + +
@@ -789,10 +857,80 @@ function shortDate(date: string) { 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) { .listing-daily-grid { grid-template-columns: 1fr; } + + .listing-channel-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (max-width: 768px) { @@ -802,6 +940,10 @@ function shortDate(date: string) { } @media (max-width: 480px) { + .listing-channel-summary { + grid-template-columns: 1fr; + } + .metric-card { flex-direction: column; align-items: flex-start; diff --git a/frontend/src/features/admin/views/AdminListingReviewView.vue b/frontend/src/features/admin/views/AdminListingReviewView.vue index e2077ac..970dfde 100644 --- a/frontend/src/features/admin/views/AdminListingReviewView.vue +++ b/frontend/src/features/admin/views/AdminListingReviewView.vue @@ -137,7 +137,12 @@ function selectListing(row: Listing) { function replaceListing(next: Listing) { const index = listings.value.findIndex(item => item.id === next.id) 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 { listings.value.unshift(next) } @@ -422,6 +427,13 @@ function contactPhone(row: Listing) { 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) { const text = row.asset_summary?.online_time_text if (typeof text === 'string' && text.trim()) return text @@ -456,7 +468,15 @@ function isDefaultScreenshot(url: string) { function riskItems(row: Listing): 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 (hasDefaultScreenshot(row)) items.push({ label: '使用默认截图', level: 'warning' }) if (hasBanRecord(row)) items.push({ label: `封禁记录:${banRecordText(row)}`, level: 'danger' }) @@ -472,7 +492,7 @@ function riskItems(row: Listing): RiskItem[] { items.push({ label: '烽火等级接近下限', level: 'warning' }) if (!readAssetString(row, 'season_insurance')) items.push({ label: '缺少保险格数', level: 'warning' }) - if (!items.length) items.push({ label: '未发现明显风险', level: 'info' }) + if (items.length === infoCount) items.push({ label: '未发现明显风险', level: 'info' }) return items } @@ -495,6 +515,7 @@ function reviewSearchText(row: Listing) { row.login_platform, row.rank_level, uploaderName(row), + sourceChannel(row), getSkinNames(row).join(' '), ] .join(' ') @@ -602,7 +623,7 @@ async function openScreenshot(url: string) { v-model="filters.keyword" :prefix-icon="Search" clearable - placeholder="搜索编号、标题、客服、段位、皮肤" + placeholder="搜索编号、标题、客服、渠道、段位、皮肤" /> @@ -642,10 +663,17 @@ async function openScreenshot(url: string) { formatCent(item.deposit_amount_cent) }} - {{ - uploaderName(item) - }} - 默认图 +
+ {{ + sourceChannel(item) + }} + {{ + uploaderName(item) + }} + 默认图 +

上传信息

- 外部上传 +
+ {{ + sourceChannel(selectedListing) + }} + 外部上传 +
+
+
来源渠道
+
{{ sourceChannel(selectedListing) }}
+
上传人
{{ uploaderName(selectedListing) }}
@@ -1206,6 +1243,14 @@ async function openScreenshot(url: string) { align-items: center; } +.queue-tags, +.panel-tags { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + .review-detail { display: flex; flex-direction: column; diff --git a/frontend/src/features/listings/api/listings.ts b/frontend/src/features/listings/api/listings.ts index ab1cfe2..de61a38 100644 --- a/frontend/src/features/listings/api/listings.ts +++ b/frontend/src/features/listings/api/listings.ts @@ -9,6 +9,7 @@ export interface Listing { owner_id: number owner_phone?: string owner_nickname?: string + source_channel?: string title: string description: string game_name: string