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