516 lines
16 KiB
Go
516 lines
16 KiB
Go
package listing
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
}
|
|
uploaderName := req.normalizedUploaderName()
|
|
if uploaderName == "" {
|
|
return nil, ErrMissingUploaderName
|
|
}
|
|
sourceChannel := req.normalizedSourceChannel()
|
|
items, err := parseExternalUploadItems(req.Data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(items) > maxExternalUploadItems {
|
|
return nil, ErrTooManyUploadItems
|
|
}
|
|
rules, err := s.publishRules(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
salePriceConfig, err := s.externalSalePriceConfig(ctx)
|
|
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, salePriceConfig)
|
|
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(ctx, externalUploadCreate{
|
|
UploaderName: uploaderName,
|
|
SourceChannel: sourceChannel,
|
|
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,
|
|
ListingNo: dto.ListingNo,
|
|
AccountID: dto.AccountID,
|
|
ListingGroupConversationID: dto.ListingGroupConversationID,
|
|
Status: dto.Status,
|
|
ReviewStatus: dto.ReviewStatus,
|
|
}
|
|
results = append(results, result)
|
|
if len(items) == 1 {
|
|
resp.ListingID = dto.ID
|
|
resp.ListingNo = dto.ListingNo
|
|
resp.AccountID = dto.AccountID
|
|
resp.ListingGroupConversationID = dto.ListingGroupConversationID
|
|
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 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
|
|
}
|
|
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,
|
|
salePriceConfig salePriceConfig,
|
|
) CreateRequest {
|
|
hafCoinM := item.Currency.HafuCoin
|
|
pureCoinPrice := 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)
|
|
remark := strings.TrimSpace(item.Remark)
|
|
onlineTimeText := normalizeExternalOnlineTimeText(item.OwnerOnlineTime)
|
|
resources := externalResources(item.Inventory)
|
|
consumablePrice := consumableValue(map[string]any{"resources": resources})
|
|
pricing := calculateExternalPlatformPricing(
|
|
hafCoinM,
|
|
ratio,
|
|
pureCoinPrice,
|
|
consumablePrice,
|
|
salePriceConfig,
|
|
)
|
|
assetSummary := map[string]any{
|
|
"face_owner": "",
|
|
"secret_kd": item.SecretKD,
|
|
"fire_level": item.Level,
|
|
"daily_loss_m": item.DailyLossM,
|
|
"publish_ratio": pricing.BuyerRatio,
|
|
"season_insurance": insurance,
|
|
"stamina_level": staminaLevel,
|
|
"load_level": loadLevel,
|
|
"resources": resources,
|
|
"skin_groups": externalSkinGroups(skins),
|
|
"online_time_text": onlineTimeText,
|
|
// 外部导入没有该字段时按不可凌晨响应处理,避免旧数据误入夜间专区。
|
|
"early_morning_response": "否",
|
|
"ban_record": normalizeBanRecord(item.BanRecord),
|
|
"common_regions": commonRegions(item.CommonRegion),
|
|
"remark": 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": roundMoney(pureCoinPrice),
|
|
"seller_total_price": roundMoney(pureCoinPrice + consumablePrice),
|
|
"consumable_price": consumablePrice,
|
|
"buyer_coin_base_price": pricing.BuyerCoinBasePrice,
|
|
"buyer_total_price": pricing.BuyerTotalPrice,
|
|
"buyer_ratio": pricing.BuyerRatio,
|
|
"platform_markup_amount": pricing.PlatformMarkupPrice,
|
|
"platform_rule_type": pricing.RuleType,
|
|
},
|
|
}
|
|
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,
|
|
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},
|
|
PriceCent: yuanToCent(pricing.BuyerTotalPrice),
|
|
DepositAmountCent: yuanToCent(item.Deposit),
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return buildExternalListingTitle(item.Rank, insurance, hafCoinM, item.LoginMethod)
|
|
}
|
|
|
|
func buildExternalListingTitle(rank string, insurance string, hafCoinM float64, loginMethod string) string {
|
|
parts := []string{
|
|
strings.TrimSpace(rank),
|
|
insurance,
|
|
fmt.Sprintf("%.1fM", hafCoinM),
|
|
strings.TrimSpace(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)
|
|
normalized := strings.ToLower(value)
|
|
switch {
|
|
case strings.Contains(value, "微信"), strings.HasPrefix(normalized, "vx"), strings.HasPrefix(normalized, "wx"):
|
|
return "微信"
|
|
case strings.Contains(normalized, "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
|
|
}
|
|
|
|
// 与 systemconfig 默认 publish_options 皮肤分组保持一致,避免详情页出现英文分组名。
|
|
var externalSkinCatalog = []struct {
|
|
key string
|
|
names []string
|
|
}{
|
|
{"melee", []string{"坠星者", "暗星", "龙牙", "信条", "怜悯", "赤枭", "影锋", "黑海", "北极星", "电锯惊魂", "处刑者"}},
|
|
{"operatorGold", []string{
|
|
"露娜-古墓丽影联动", "蛊-不羁人生", "红狼-电锯惊魂", "露娜-金牌射手",
|
|
"牧羊人-街头之星", "蜂医-危险物质", "蜂医-送葬人", "无名-夜鹰",
|
|
"威龙-壮志凌云", "威龙-蛟龙特战队", "威龙-铁面判官", "威龙-吴彦祖",
|
|
}},
|
|
{"operatorRed", []string{"凌霄成卫", "蚀金玫瑰", "水墨云图", "午夜邮差", "天际线", "维什戴尔"}},
|
|
{"weapon", []string{
|
|
"M7棱镜攻势S2", "腾龙气象感应", "MP7电玩高手S2", "M250电玩高手S2",
|
|
"AS Val悬赏令", "K416命运", "M4A1棱镜攻势", "SCAR-H电玩高手",
|
|
"QBZ95王牌之剑", "AUG气象感应", "Vector美杜莎", "KC17-造物纪元",
|
|
}},
|
|
}
|
|
|
|
func externalSkinGroups(skins []string) map[string][]string {
|
|
groups := map[string][]string{}
|
|
for _, skin := range skins {
|
|
skin = strings.TrimSpace(skin)
|
|
if skin == "" {
|
|
continue
|
|
}
|
|
key := classifyExternalSkin(skin)
|
|
groups[key] = append(groups[key], skin)
|
|
}
|
|
return groups
|
|
}
|
|
|
|
func classifyExternalSkin(skin string) string {
|
|
// 先精确匹配,再做包含匹配,兼容“红狼-电锯惊魂 / 电锯惊魂”这类写法。
|
|
for _, group := range externalSkinCatalog {
|
|
for _, name := range group.names {
|
|
if skin == name {
|
|
return group.key
|
|
}
|
|
}
|
|
}
|
|
for _, group := range externalSkinCatalog {
|
|
for _, name := range group.names {
|
|
if strings.Contains(skin, name) || strings.Contains(name, skin) {
|
|
return group.key
|
|
}
|
|
}
|
|
}
|
|
return "other"
|
|
}
|
|
|
|
func normalizeBanRecord(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
switch value {
|
|
case "", "无", "无封禁", "无封禁记录", "否", "没有":
|
|
return "无封禁记录"
|
|
case "有", "是", "有封禁", "有封禁记录":
|
|
return "有封禁记录"
|
|
default:
|
|
// 兼容历史详情文案:非空即视为有封禁
|
|
return "有封禁记录"
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|