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 } 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(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) 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) remark := strings.TrimSpace(item.Remark) onlineTimeText := normalizeExternalOnlineTimeText(item.OwnerOnlineTime) 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": onlineTimeText, "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": 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", }, } 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(price), 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 { 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 } // 与 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 }