package listing import ( "context" "encoding/json" "fmt" "math" "regexp" "strconv" "strings" "time" "hfb_sys/backend/internal/modules/systemconfig" ) 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 } // 皮肤分组与消耗品单价取自后台实时配置,避免代码里的快照与线上选项脱节。 publishOptions := s.externalPublishOptions(ctx) 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, publishOptions) 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, publishOptions systemconfig.PublishOptionsDTO, ) 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, publishOptions.QuantityItems) 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, publishOptions.SkinGroups), "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) + "级" } // inventoryQuantities 把上传契约的库存字段映射回 publish_options.quantity_items 的 key。 // 历史字段名(awmBullets / level6Helmets / level6Armor)保持不变,以兼容既有上传客户端。 func inventoryQuantities(inventory ExternalUploadInventory) map[string]int { return map[string]int{ "awmAmmo": inventory.AWMBullets, "helmet6": inventory.Level6Helmets, "armor6": inventory.Level6Armor, "level6Ammo": inventory.Level6Ammo, "barrettAmmo": inventory.BarrettAmmo, "gridCard9": inventory.GridCard9, "redPacket45": inventory.RedPacket45, "coffeeBean": inventory.CoffeeBean, "bulletPart": inventory.BulletPart, } } // externalResources 按后台实时配置的消耗品清单生成资源数组:条目顺序、名称与单价取自配置, // 数量取自上传数据;未填(<=0)的条目与网站自身发布流程保持一致地省略。 func externalResources(inventory ExternalUploadInventory, items []systemconfig.PublishQuantityItem) []any { quantities := inventoryQuantities(inventory) resources := make([]any, 0, len(items)) for _, item := range items { quantity := quantities[item.Key] if quantity <= 0 { continue } resources = append(resources, map[string]any{ "key": item.Key, "label": item.Label, "price": item.Price, "quantity": quantity, "mode": "收费", }) } return resources } // externalSkinGroups 依据后台实时配置的皮肤分组归类上传的皮肤名, // 避免代码里的字典快照与运营在后台维护的选项脱节(新增皮肤会被误判为“其他皮肤”)。 func externalSkinGroups(skins []string, groups []systemconfig.PublishOptionGroup) map[string][]string { result := map[string][]string{} for _, skin := range skins { skin = strings.TrimSpace(skin) if skin == "" { continue } key := classifyExternalSkin(skin, groups) result[key] = append(result[key], skin) } return result } func classifyExternalSkin(skin string, groups []systemconfig.PublishOptionGroup) string { // 先精确匹配,再做包含匹配,兼容“红狼-电锯惊魂 / 电锯惊魂”这类写法。 for _, group := range groups { for _, name := range group.Options { if skin == name { return group.Key } } } for _, group := range groups { for _, name := range group.Options { 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 }