增加渠道上传预统计
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user