增加渠道上传预统计

This commit is contained in:
yml2213
2026-07-25 14:34:03 +08:00
parent 1ea01f7d46
commit 7ca68e6b8b
15 changed files with 689 additions and 63 deletions
+1
View File
@@ -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"`
+18 -8
View File
@@ -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)
}
}
+14 -4
View File
@@ -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"`
@@ -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, ","))
}
@@ -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,
@@ -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,
+17 -2
View File
@@ -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) {
+148 -10
View File
@@ -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-100",
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)