增加上传接口

This commit is contained in:
yml
2026-06-02 17:51:04 +08:00
parent 6f4c8b59f9
commit 3adba1c529
9 changed files with 825 additions and 2 deletions
+80 -1
View File
@@ -1,6 +1,10 @@
package listing
import "time"
import (
"encoding/json"
"strings"
"time"
)
type ListingDTO struct {
ID uint64 `json:"id"`
@@ -116,3 +120,78 @@ type AuditMeta struct {
IP string
UserAgent string
}
type ExternalUploadRequest struct {
UploadTime int64 `json:"uploadTime"`
UploaderName string `json:"uploaderName"`
Uploaderame string `json:"uploaderame"`
Data json.RawMessage `json:"data"`
}
func (r ExternalUploadRequest) normalizedUploaderName() string {
if name := strings.TrimSpace(r.UploaderName); name != "" {
return name
}
return strings.TrimSpace(r.Uploaderame)
}
type ExternalAccountData struct {
LoginMethod string `json:"loginMethod"`
Rank string `json:"rank"`
Level int `json:"level"`
SafeSlots int `json:"safeSlots"`
SecretKD float64 `json:"secretKD"`
DailyLossM float64 `json:"dailyLossM"`
Deposit float64 `json:"deposit"`
BanRecord string `json:"banRecord"`
CommonRegion string `json:"commonRegion"`
ContactPhone string `json:"contactPhone"`
OwnerOnlineTime string `json:"ownerOnlineTime"`
Currency ExternalUploadCurrency `json:"currency"`
DailyConsumption ExternalDailyConsumption `json:"dailyConsumption"`
Inventory ExternalUploadInventory `json:"inventory"`
}
type ExternalUploadCurrency struct {
HafuCoin float64 `json:"hafuCoin"`
RecycleRatio float64 `json:"recycleRatio"`
RecycleRent float64 `json:"recycleRent"`
}
type ExternalDailyConsumption struct {
Stamina int `json:"stamina"`
Weight int `json:"weight"`
}
type ExternalUploadInventory struct {
AWMBullets int `json:"awmBullets"`
Level6Helmets int `json:"level6Helmets"`
Level6Armor int `json:"level6Armor"`
Skins []string `json:"skins"`
}
type ExternalUploadMeta struct {
IP string
UserAgent string
RawPayload []byte
}
type ExternalUploadResult struct {
Index int `json:"index"`
ListingID uint64 `json:"listing_id,omitempty"`
AccountID uint64 `json:"account_id,omitempty"`
Status string `json:"status,omitempty"`
ReviewStatus string `json:"review_status,omitempty"`
Error string `json:"error,omitempty"`
}
type ExternalUploadResponse struct {
ListingID uint64 `json:"listing_id,omitempty"`
AccountID uint64 `json:"account_id,omitempty"`
Status string `json:"status,omitempty"`
ReviewStatus string `json:"review_status,omitempty"`
Total int `json:"total"`
Success int `json:"success"`
Failed int `json:"failed"`
Items []ExternalUploadResult `json:"items,omitempty"`
}
@@ -1,7 +1,9 @@
package listing
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
@@ -18,6 +20,8 @@ type Handler struct {
storage *filemodule.Storage
}
const maxExternalUploadBodyBytes = 256 * 1024
func NewHandler(service *Service, storage *filemodule.Storage) *Handler {
return &Handler{service: service, storage: storage}
}
@@ -41,6 +45,36 @@ func (h *Handler) Create(c *gin.Context) {
response.Created(c, item)
}
func (h *Handler) ImportExternalUpload(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxExternalUploadBodyBytes)
raw, err := io.ReadAll(c.Request.Body)
if err != nil {
response.BadRequest(c, "上传内容过大或读取失败")
return
}
var req ExternalUploadRequest
if err := json.Unmarshal(raw, &req); err != nil {
response.BadRequest(c, "上传 JSON 格式不正确")
return
}
result, err := h.service.ImportExternalUpload(req, ExternalUploadMeta{
IP: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
RawPayload: raw,
})
if err != nil {
writeListingError(c, err)
return
}
response.Created(c, result)
}
func (h *Handler) DefaultUploadScreenshot(c *gin.Context) {
c.Header("Content-Type", "image/svg+xml; charset=utf-8")
c.Header("Cache-Control", "public, max-age=86400")
c.String(http.StatusOK, `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540"><rect width="960" height="540" fill="#f3f4f6"/><rect x="72" y="72" width="816" height="396" rx="24" fill="#ffffff" stroke="#d1d5db" stroke-width="4"/><text x="480" y="248" text-anchor="middle" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#374151">账号资料默认图</text><text x="480" y="310" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" fill="#6b7280">开放接口上传,待后台补充审核</text></svg>`)
}
func (h *Handler) Update(c *gin.Context) {
ownerID, ok := currentUserID(c)
if !ok {
@@ -512,6 +546,18 @@ func writeListingError(c *gin.Context, err error) {
response.BadRequest(c, "哈夫币数量不正确")
case errors.Is(err, ErrMissingScreenshot):
response.BadRequest(c, "请至少上传一张账号截图")
case errors.Is(err, ErrMissingUploaderName):
response.BadRequest(c, "上传人名称不能为空")
case errors.Is(err, ErrMissingUploadData):
response.BadRequest(c, "上传账号数据不能为空")
case errors.Is(err, ErrUploaderNotFound):
response.BadRequest(c, "未匹配到可用后台用户")
case errors.Is(err, ErrUploaderAmbiguous):
response.BadRequest(c, "上传人名称匹配到多个后台用户,请使用唯一用户名")
case errors.Is(err, ErrTooManyUploadItems):
response.BadRequest(c, "单次上传账号数量过多")
case isUploadValidationError(err):
response.BadRequest(c, err.Error())
case isFireLevelTooLow(err):
var levelErr FireLevelTooLowError
errors.As(err, &levelErr)
@@ -531,3 +577,8 @@ func isFireLevelTooLow(err error) bool {
var levelErr FireLevelTooLowError
return errors.As(err, &levelErr)
}
func isUploadValidationError(err error) bool {
var validationErr UploadValidationError
return errors.As(err, &validationErr)
}
@@ -3,6 +3,7 @@ package listing
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strconv"
@@ -81,6 +82,83 @@ func (r *Repository) Create(ownerID uint64, req CreateRequest, reviewRequired bo
return dto, err
}
type externalUploadCreate struct {
UploaderName string
ClientUploadTime *time.Time
ClientIP string
RawPayload []byte
ParsedPayload []byte
}
func (r *Repository) CreateFromExternalUpload(upload externalUploadCreate, req CreateRequest) (*ListingDTO, error) {
var dto *ListingDTO
err := r.db.Transaction(func(tx *gorm.DB) error {
admin, err := r.findActiveUploadAdmin(tx, upload.UploaderName)
if err != nil {
return err
}
owner, err := r.ensureUploadOwnerUser(tx, admin)
if err != nil {
return err
}
screenshots, err := marshalScreenshots(req.ScreenshotURLS)
if err != nil {
return err
}
assetSummary, err := marshalAssetSummary(req.AssetSummary)
if err != nil {
return err
}
account := model.GameAccount{
OwnerID: owner.ID,
GameName: "delta_force",
ServerRegion: req.ServerRegion,
LoginPlatform: req.LoginPlatform,
Title: req.Title,
Description: req.Description,
RankLevel: req.RankLevel,
HafCoinAmount: req.HafCoinAmount,
AssetSummary: assetSummary,
ScreenshotURLS: screenshots,
Status: "draft",
}
if err := tx.Create(&account).Error; err != nil {
return err
}
listing := model.RentalListing{
AccountID: account.ID,
OwnerID: owner.ID,
Price: normalizedListingPrice(req),
DepositAmount: roundMoney(req.DepositAmount),
Status: "draft",
ReviewStatus: "pending",
}
if err := tx.Create(&listing).Error; err != nil {
return err
}
matchedAdminID := admin.ID
ownerID := owner.ID
listingID := listing.ID
uploadRow := model.ListingUpload{
UploaderName: upload.UploaderName,
MatchedAdminID: &matchedAdminID,
OwnerID: &ownerID,
ClientUploadTime: upload.ClientUploadTime,
ClientIP: upload.ClientIP,
RawPayload: datatypes.JSON(upload.RawPayload),
ParsedPayload: datatypes.JSON(upload.ParsedPayload),
ListingID: &listingID,
Status: "draft_created",
}
if err := tx.Create(&uploadRow).Error; err != nil {
return err
}
dto = toDTO(account, listing)
return nil
})
return dto, err
}
func (r *Repository) Update(ownerID uint64, listingID uint64, req UpdateRequest, reviewRequired bool) (*ListingDTO, error) {
var dto *ListingDTO
err := r.db.Transaction(func(tx *gorm.DB) error {
@@ -224,6 +302,68 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g
return db
}
func (r *Repository) findActiveUploadAdmin(tx *gorm.DB, uploaderName string) (*model.AdminUser, error) {
uploaderName = strings.TrimSpace(uploaderName)
var admin model.AdminUser
if err := tx.Where("username = ? AND status = ?", uploaderName, "active").First(&admin).Error; err == nil {
return &admin, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
var admins []model.AdminUser
if err := tx.Where("nickname = ? AND status = ?", uploaderName, "active").Limit(2).Find(&admins).Error; err != nil {
return nil, err
}
switch len(admins) {
case 0:
return nil, ErrUploaderNotFound
case 1:
return &admins[0], nil
default:
return nil, ErrUploaderAmbiguous
}
}
func (r *Repository) ensureUploadOwnerUser(tx *gorm.DB, admin *model.AdminUser) (*model.User, error) {
phone := fmt.Sprintf("admin:%d", admin.ID)
nickname := strings.TrimSpace(admin.Nickname)
if nickname == "" {
nickname = admin.Username
}
var user model.User
err := tx.Where("phone = ?", phone).First(&user).Error
if err == nil {
updates := map[string]any{
"nickname": nickname,
"status": "active",
"realname_status": "verified",
}
if err := tx.Model(&user).Updates(updates).Error; err != nil {
return nil, err
}
user.Nickname = nickname
user.Status = "active"
user.RealnameStatus = "verified"
return &user, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
user = model.User{
Phone: phone,
Nickname: nickname,
RealnameStatus: "verified",
RiskStatus: "normal",
CreditScore: 100,
Status: "active",
}
if err := tx.Create(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *Repository) FindAdmin(listingID uint64) (*ListingDTO, error) {
return r.findDTO("l.id = ?", listingID)
}
+395
View File
@@ -3,10 +3,12 @@ package listing
import (
"encoding/json"
"errors"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
)
var (
@@ -20,6 +22,11 @@ var (
ErrDepositTooLow = errors.New("listing deposit too low")
ErrInvalidHafCoin = errors.New("invalid haf coin amount")
ErrMissingScreenshot = errors.New("missing screenshot")
ErrMissingUploaderName = errors.New("missing uploader name")
ErrMissingUploadData = errors.New("missing upload data")
ErrUploaderNotFound = errors.New("uploader not found")
ErrUploaderAmbiguous = errors.New("uploader ambiguous")
ErrTooManyUploadItems = errors.New("too many upload items")
)
type Service struct {
@@ -35,6 +42,8 @@ const (
reviewRequiredConfigKey = "listing.review_required"
publishOptionsConfigKey = "listing.publish_options"
defaultFireLevelMin = 38
maxExternalUploadItems = 10
defaultUploadScreenshot = "/api/listings/default-upload-screenshot"
)
var priceNumberPattern = regexp.MustCompile(`(\d+(?:\.\d+)?)`)
@@ -47,6 +56,22 @@ func (e FireLevelTooLowError) Error() string {
return "fire level too low"
}
type UploadValidationError struct {
Missing []string
Invalid []string
}
func (e UploadValidationError) Error() string {
parts := make([]string, 0, 2)
if len(e.Missing) > 0 {
parts = append(parts, "缺少必填字段:"+strings.Join(e.Missing, "、"))
}
if len(e.Invalid) > 0 {
parts = append(parts, "字段格式不正确:"+strings.Join(e.Invalid, "、"))
}
return strings.Join(parts, "")
}
func NewService(repo *Repository, config ConfigReader) *Service {
return &Service{repo: repo, config: config}
}
@@ -69,6 +94,77 @@ func (s *Service) Create(ownerID uint64, req CreateRequest) (*ListingDTO, error)
return s.repo.Create(ownerID, req, reviewRequired)
}
func (s *Service) ImportExternalUpload(req ExternalUploadRequest, meta ExternalUploadMeta) (*ExternalUploadResponse, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
uploaderName := req.normalizedUploaderName()
if uploaderName == "" {
return nil, ErrMissingUploaderName
}
items, err := parseExternalUploadItems(req.Data)
if err != nil {
return nil, err
}
if len(items) > maxExternalUploadItems {
return nil, ErrTooManyUploadItems
}
rules, err := s.publishRules()
if err != nil {
return nil, err
}
clientUploadTime := parseClientUploadTime(req.UploadTime)
results := make([]ExternalUploadResult, 0, len(items))
resp := &ExternalUploadResponse{Total: len(items)}
for index, item := range items {
createReq := externalAccountToCreateRequest(uploaderName, req.UploadTime, item)
if err := validateRequest(createReq, rules); err != nil {
if len(items) == 1 {
return nil, err
}
resp.Failed++
results = append(results, ExternalUploadResult{Index: index, Error: err.Error()})
continue
}
parsedPayload, _ := json.Marshal(item)
dto, err := s.repo.CreateFromExternalUpload(externalUploadCreate{
UploaderName: uploaderName,
ClientUploadTime: clientUploadTime,
ClientIP: meta.IP,
RawPayload: meta.RawPayload,
ParsedPayload: parsedPayload,
}, createReq)
if err != nil {
if len(items) == 1 {
return nil, err
}
resp.Failed++
results = append(results, ExternalUploadResult{Index: index, Error: err.Error()})
continue
}
resp.Success++
result := ExternalUploadResult{
Index: index,
ListingID: dto.ID,
AccountID: dto.AccountID,
Status: dto.Status,
ReviewStatus: dto.ReviewStatus,
}
results = append(results, result)
if len(items) == 1 {
resp.ListingID = dto.ID
resp.AccountID = dto.AccountID
resp.Status = dto.Status
resp.ReviewStatus = dto.ReviewStatus
}
}
resp.Items = results
if resp.Success == 0 && resp.Failed > 0 {
return resp, ErrInvalidInput
}
return resp, nil
}
func (s *Service) Update(ownerID uint64, id uint64, req UpdateRequest) (*ListingDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
@@ -237,6 +333,305 @@ func validateRequest(req CreateRequest, rules publishRules) error {
return nil
}
func parseExternalUploadItems(raw json.RawMessage) ([]ExternalAccountData, error) {
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || string(raw) == "null" {
return nil, ErrMissingUploadData
}
var singleRaw map[string]json.RawMessage
if err := json.Unmarshal(raw, &singleRaw); err == nil {
if err := validateExternalUploadRaw(singleRaw, "data"); err != nil {
return nil, err
}
var single ExternalAccountData
if err := json.Unmarshal(raw, &single); err != nil {
return nil, ErrMissingUploadData
}
return []ExternalAccountData{single}, nil
}
var rawItems []json.RawMessage
if err := json.Unmarshal(raw, &rawItems); err != nil {
return nil, ErrMissingUploadData
}
if len(rawItems) == 0 {
return nil, ErrMissingUploadData
}
items := make([]ExternalAccountData, 0, len(rawItems))
for index, rawItem := range rawItems {
var rawMap map[string]json.RawMessage
if err := json.Unmarshal(rawItem, &rawMap); err != nil {
return nil, UploadValidationError{Invalid: []string{fmt.Sprintf("data[%d]", index)}}
}
prefix := fmt.Sprintf("data[%d]", index)
if err := validateExternalUploadRaw(rawMap, prefix); err != nil {
return nil, err
}
var item ExternalAccountData
if err := json.Unmarshal(rawItem, &item); err != nil {
return nil, UploadValidationError{Invalid: []string{prefix}}
}
items = append(items, item)
}
return items, nil
}
func validateExternalUploadRaw(raw map[string]json.RawMessage, prefix string) error {
var validation UploadValidationError
requireStringField(raw, prefix, "loginMethod", &validation)
requireStringField(raw, prefix, "rank", &validation)
requireNumberField(raw, prefix, "level", &validation)
requireNumberField(raw, prefix, "safeSlots", &validation)
requireNumberField(raw, prefix, "secretKD", &validation)
requireNumberField(raw, prefix, "deposit", &validation)
requireNumberField(raw, prefix, "dailyLossM", &validation)
currency := requireObjectField(raw, prefix, "currency", &validation)
requireNumberField(currency, prefix+".currency", "hafuCoin", &validation)
requireNumberField(currency, prefix+".currency", "recycleRatio", &validation)
requireNumberField(currency, prefix+".currency", "recycleRent", &validation)
dailyConsumption := requireObjectField(raw, prefix, "dailyConsumption", &validation)
requireNumberField(dailyConsumption, prefix+".dailyConsumption", "stamina", &validation)
requireNumberField(dailyConsumption, prefix+".dailyConsumption", "weight", &validation)
if len(validation.Missing) > 0 || len(validation.Invalid) > 0 {
return validation
}
return nil
}
func requireObjectField(raw map[string]json.RawMessage, prefix string, field string, validation *UploadValidationError) map[string]json.RawMessage {
if raw == nil {
validation.Missing = append(validation.Missing, prefix+"."+field)
return nil
}
value, ok := raw[field]
if !ok || isJSONNull(value) {
validation.Missing = append(validation.Missing, prefix+"."+field)
return nil
}
var object map[string]json.RawMessage
if err := json.Unmarshal(value, &object); err != nil {
validation.Invalid = append(validation.Invalid, prefix+"."+field)
return nil
}
return object
}
func requireStringField(raw map[string]json.RawMessage, prefix string, field string, validation *UploadValidationError) {
value, ok := raw[field]
if !ok || isJSONNull(value) {
validation.Missing = append(validation.Missing, prefix+"."+field)
return
}
var text string
if err := json.Unmarshal(value, &text); err != nil {
validation.Invalid = append(validation.Invalid, prefix+"."+field)
return
}
if strings.TrimSpace(text) == "" {
validation.Missing = append(validation.Missing, prefix+"."+field)
}
}
func requireNumberField(raw map[string]json.RawMessage, prefix string, field string, validation *UploadValidationError) {
if raw == nil {
validation.Missing = append(validation.Missing, prefix+"."+field)
return
}
value, ok := raw[field]
if !ok || isJSONNull(value) {
validation.Missing = append(validation.Missing, prefix+"."+field)
return
}
var number json.Number
decoder := json.NewDecoder(strings.NewReader(string(value)))
decoder.UseNumber()
if err := decoder.Decode(&number); err != nil {
validation.Invalid = append(validation.Invalid, prefix+"."+field)
}
}
func isJSONNull(value json.RawMessage) bool {
return strings.TrimSpace(string(value)) == "null"
}
func parseClientUploadTime(value int64) *time.Time {
if value <= 0 {
return nil
}
if value > 1_000_000_000_000 {
parsed := time.UnixMilli(value)
return &parsed
}
parsed := time.Unix(value, 0)
return &parsed
}
func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item ExternalAccountData) CreateRequest {
hafCoinM := item.Currency.HafuCoin
price := item.Currency.RecycleRent
ratio := item.Currency.RecycleRatio
insurance := insuranceFromSafeSlots(item.SafeSlots)
staminaLevel := levelText(item.DailyConsumption.Stamina)
loadLevel := levelText(item.DailyConsumption.Weight)
skins := cleanStrings(item.Inventory.Skins)
assetSummary := map[string]any{
"face_owner": "",
"secret_kd": item.SecretKD,
"fire_level": item.Level,
"daily_loss_m": item.DailyLossM,
"publish_ratio": ratio,
"season_insurance": insurance,
"stamina_level": staminaLevel,
"load_level": loadLevel,
"resources": externalResources(item.Inventory),
"skin_groups": externalSkinGroups(skins),
"online_time_text": strings.TrimSpace(item.OwnerOnlineTime),
"ban_record": normalizeBanRecord(item.BanRecord),
"common_regions": commonRegions(item.CommonRegion),
"remark": "开放接口自动上传,等待后台审核。",
"import_meta": map[string]any{
"uploader_name": uploaderName,
"client_upload_time": uploadTime,
"contact_phone": strings.TrimSpace(item.ContactPhone),
},
"price_breakdown": map[string]any{
"seller_reference_ratio": ratio,
"seller_ratio": ratio,
"seller_coin_base_price": price,
"seller_total_price": price,
"consumable_price": consumableValue(map[string]any{"resources": externalResources(item.Inventory)}),
"buyer_coin_base_price": price,
"buyer_total_price": price,
"buyer_ratio": ratio,
"platform_markup_amount": 0,
"platform_rule_type": "external_upload",
},
}
return CreateRequest{
Title: externalUploadTitle(item, insurance, hafCoinM),
Description: "开放接口自动上传,等待后台审核。",
ServerRegion: serverRegionFromLoginMethod(item.LoginMethod),
LoginPlatform: strings.TrimSpace(item.LoginMethod),
RankLevel: strings.TrimSpace(item.Rank),
HafCoinAmount: int64(math.Round(hafCoinM * 1000000)),
AssetSummary: assetSummary,
ScreenshotURLS: []string{defaultUploadScreenshot},
Price: price,
DepositAmount: item.Deposit,
}
}
func externalUploadTitle(item ExternalAccountData, insurance string, hafCoinM float64) string {
parts := []string{
strings.TrimSpace(item.Rank),
insurance,
fmt.Sprintf("%.1fM", hafCoinM),
strings.TrimSpace(item.LoginMethod),
}
title := strings.TrimSpace(strings.Join(cleanStrings(parts), " "))
if title == "" {
return "开放接口上传账号"
}
if len([]rune(title)) > 128 {
return string([]rune(title)[:128])
}
return title
}
func serverRegionFromLoginMethod(value string) string {
value = strings.TrimSpace(value)
switch {
case strings.Contains(value, "微信"):
return "微信"
case strings.Contains(strings.ToLower(value), "steam"):
return "Steam"
default:
return "QQ"
}
}
func insuranceFromSafeSlots(value int) string {
switch value {
case 9:
return "3*3"
case 6:
return "2*3"
case 4:
return "2*2"
case 2:
return "2*1"
default:
if value > 0 {
return strconv.Itoa(value) + "格"
}
return ""
}
}
func levelText(value int) string {
if value <= 0 {
return ""
}
return strconv.Itoa(value) + "级"
}
func externalResources(inventory ExternalUploadInventory) []any {
resources := []any{
map[string]any{"key": "awmAmmo", "label": "AWM子弹", "price": "0.6元/发", "quantity": inventory.AWMBullets, "mode": "收费"},
map[string]any{"key": "helmet6", "label": "6头", "price": "1.5元/个", "quantity": inventory.Level6Helmets, "mode": "收费"},
map[string]any{"key": "armor6", "label": "6甲", "price": "2.5元/个", "quantity": inventory.Level6Armor, "mode": "收费"},
}
return resources
}
func externalSkinGroups(skins []string) map[string][]string {
groups := map[string][]string{
"melee": {},
"imported": {},
}
for _, skin := range skins {
switch skin {
case "坠星者", "暗星", "龙牙", "信条", "怜悯", "赤枭", "影锋", "黑海", "北极星", "电锯惊魂", "处刑者":
groups["melee"] = append(groups["melee"], skin)
default:
groups["imported"] = append(groups["imported"], skin)
}
}
return groups
}
func normalizeBanRecord(value string) string {
value = strings.TrimSpace(value)
switch value {
case "", "无", "无封禁", "无封禁记录":
return "无封禁记录"
default:
return value
}
}
func commonRegions(value string) []string {
return cleanStrings(strings.Split(value, ","))
}
func cleanStrings(values []string) []string {
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func consumableValue(summary map[string]any) float64 {
if summary == nil {
return 0
@@ -1,6 +1,9 @@
package listing
import "testing"
import (
"encoding/json"
"testing"
)
func TestConsumableValueOnlyCountsChargedResources(t *testing.T) {
value := consumableValue(map[string]any{
@@ -74,3 +77,105 @@ func TestApplySellerListingPriceKeepsFallbackPrice(t *testing.T) {
t.Fatalf("expected fallback price 238, got %.2f", item.Price)
}
}
func TestParseExternalUploadItemsAcceptsSingleObject(t *testing.T) {
items, err := parseExternalUploadItems(json.RawMessage(validExternalUploadDataJSON()))
if err != nil {
t.Fatalf("expected single upload parsed, got %v", err)
}
if len(items) != 1 || items[0].LoginMethod != "QQ账号密码" {
t.Fatalf("unexpected items: %#v", items)
}
}
func TestParseExternalUploadItemsReportsMissingFields(t *testing.T) {
_, err := parseExternalUploadItems(json.RawMessage(`{"loginMethod":"QQ账号密码","currency":{"hafuCoin":197.1}}`))
if err == nil {
t.Fatal("expected validation error")
}
validationErr, ok := err.(UploadValidationError)
if !ok {
t.Fatalf("expected UploadValidationError, got %T", err)
}
expected := []string{"data.rank", "data.level", "data.currency.recycleRatio", "data.dailyConsumption"}
for _, field := range expected {
if !containsString(validationErr.Missing, field) {
t.Fatalf("expected missing %s in %#v", field, validationErr.Missing)
}
}
}
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: "郑州",
Currency: ExternalUploadCurrency{
HafuCoin: 197.1,
RecycleRatio: 43,
RecycleRent: 458,
},
DailyConsumption: ExternalDailyConsumption{Stamina: 7, Weight: 7},
Inventory: ExternalUploadInventory{
AWMBullets: 45,
Level6Helmets: 14,
Level6Armor: 17,
Skins: []string{"信条", "电锯惊魂"},
},
})
if req.ServerRegion != "QQ" {
t.Fatalf("expected QQ server region, got %q", req.ServerRegion)
}
if req.HafCoinAmount != 197100000 {
t.Fatalf("expected haf coin amount 197100000, got %d", req.HafCoinAmount)
}
if req.Price != 458 || req.DepositAmount != 400 {
t.Fatalf("unexpected price/deposit %.2f/%.2f", req.Price, req.DepositAmount)
}
if req.AssetSummary["season_insurance"] != "3*3" {
t.Fatalf("expected 3*3 insurance, got %#v", req.AssetSummary["season_insurance"])
}
if req.AssetSummary["daily_loss_m"] != float64(7) {
t.Fatalf("expected daily loss 7, got %#v", req.AssetSummary["daily_loss_m"])
}
if req.AssetSummary["stamina_level"] != "7级" || req.AssetSummary["load_level"] != "7级" {
t.Fatalf("unexpected stamina/load: %#v", req.AssetSummary)
}
groups := req.AssetSummary["skin_groups"].(map[string][]string)
if len(groups["melee"]) != 2 {
t.Fatalf("expected melee skins mapped, got %#v", groups)
}
if len(req.ScreenshotURLS) != 1 || req.ScreenshotURLS[0] != defaultUploadScreenshot {
t.Fatalf("expected default screenshot, got %#v", req.ScreenshotURLS)
}
}
func validExternalUploadDataJSON() string {
return `{
"loginMethod":"QQ账号密码",
"rank":"黑鹰",
"level":60,
"safeSlots":9,
"secretKD":1.6,
"dailyLossM":7,
"deposit":400,
"currency":{"hafuCoin":197.1,"recycleRatio":43,"recycleRent":458},
"dailyConsumption":{"stamina":7,"weight":7}
}`
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}