package listing import ( "encoding/json" "errors" "fmt" "math" "regexp" "strconv" "strings" "time" ) var ( ErrDependencyUnavailable = errors.New("dependency unavailable") ErrInvalidInput = errors.New("invalid listing input") ErrListingLocked = errors.New("listing locked") ErrMissingTitle = errors.New("missing listing title") ErrMissingServerRegion = errors.New("missing server region") ErrInvalidPrice = errors.New("invalid listing price") ErrInvalidDeposit = errors.New("invalid listing deposit") ErrDepositTooLow = errors.New("listing deposit too low") ErrInvalidHafCoin = errors.New("invalid haf coin amount") ErrMissingScreenshot = errors.New("missing screenshot") ErrAgreementRequired = errors.New("listing publish agreement required") 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 { repo *Repository config ConfigReader } type ConfigReader interface { FindValue(key string) (string, error) } 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+)?)`) type FireLevelTooLowError struct { Min int } 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} } func (s *Service) Create(ownerID uint64, req CreateRequest) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } if !req.AgreedVirtualAssetSale || !req.AgreedSellerAgreement { return nil, ErrAgreementRequired } rules, err := s.publishRules() if err != nil { return nil, err } if err := validateRequest(req, rules); err != nil { return nil, err } reviewRequired, err := s.reviewRequired() if err != nil { return nil, err } 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, ListingNo: dto.ListingNo, AccountID: dto.AccountID, 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.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 } rules, err := s.publishRules() if err != nil { return nil, err } if err := validateRequest(req, rules); err != nil { return nil, err } reviewRequired, err := s.reviewRequired() if err != nil { return nil, err } return s.repo.Update(ownerID, id, req, reviewRequired) } func (s *Service) SubmitReview(ownerID uint64, id uint64) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } reviewRequired, err := s.reviewRequired() if err != nil { return nil, err } return s.repo.SubmitReview(ownerID, id, reviewRequired) } func (s *Service) ListPendingReview() ([]ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } return s.repo.ListPendingReview() } func (s *Service) ListAdmin(query AdminListQuery) (*AdminListResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } return s.repo.ListAdmin(query) } func (s *Service) FindAdmin(id uint64) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } return s.repo.FindAdmin(id) } func (s *Service) AdminOffline(adminID uint64, id uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } if req.Reason == "" { return nil, ErrInvalidInput } return s.repo.AdminOffline(adminID, id, req, meta) } func (s *Service) AdminMarkAbnormal(adminID uint64, id uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } if req.Reason == "" { return nil, ErrInvalidInput } return s.repo.AdminMarkAbnormal(adminID, id, req, meta) } func (s *Service) Approve(id uint64) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } return s.repo.Approve(id) } func (s *Service) AdjustReviewPrice(adminID uint64, id uint64, req AdminPriceAdjustRequest, meta AuditMeta) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } if req.BuyerRatio <= 0 && req.BuyerTotalPriceCent <= 0 { return nil, ErrInvalidPrice } return s.repo.AdjustReviewPrice(adminID, id, req, meta) } func (s *Service) Reject(id uint64, req ReviewRequest) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } if req.Reason == "" { return nil, ErrInvalidInput } return s.repo.Reject(id, req) } func (s *Service) Offline(ownerID uint64, id uint64) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } return s.repo.Offline(ownerID, id) } func (s *Service) ListPublic(query PublicListQuery) (*PublicListResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } return s.repo.ListPublic(query) } func (s *Service) ListMine(ownerID uint64) ([]ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } return s.repo.ListMine(ownerID) } func (s *Service) FindPublic(id uint64) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } return s.repo.FindPublic(id) } func (s *Service) FindPublicCoverKey(id uint64) (string, error) { if s.repo == nil { return "", ErrDependencyUnavailable } return s.repo.FindPublicCoverKey(id) } func (s *Service) FindPublicScreenshotKey(id uint64, index int) (string, error) { if s.repo == nil { return "", ErrDependencyUnavailable } return s.repo.FindPublicScreenshotKey(id, index) } func (s *Service) FindMine(ownerID uint64, id uint64) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } return s.repo.FindMine(ownerID, id) } type publishRules struct { FireLevelMin int } func validateRequest(req CreateRequest, rules publishRules) error { if strings.TrimSpace(req.Title) == "" { return ErrMissingTitle } if strings.TrimSpace(req.ServerRegion) == "" { return ErrMissingServerRegion } if normalizedListingPriceCent(req) <= 0 { return ErrInvalidPrice } if req.DepositAmountCent < 0 { return ErrInvalidDeposit } if consumables := consumableValue(req.AssetSummary); consumables > 0 && req.DepositAmountCent <= yuanToCent(consumables) { return ErrDepositTooLow } if req.HafCoinAmount < 0 { return ErrInvalidHafCoin } if !hasScreenshotURL(req.ScreenshotURLS) { return ErrMissingScreenshot } if fireLevel, ok := readFireLevel(req.AssetSummary); ok && fireLevel < rules.FireLevelMin { return FireLevelTooLowError{Min: rules.FireLevelMin} } 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}, PriceCent: yuanToCent(price), DepositAmountCent: yuanToCent(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 } rawResources, ok := summary["resources"] if !ok { return 0 } resources, ok := rawResources.([]any) if !ok { return 0 } total := 0.0 for _, raw := range resources { resource, ok := raw.(map[string]any) if !ok { continue } mode, _ := resource["mode"].(string) if strings.TrimSpace(mode) != "收费" { continue } quantity := readSummaryFloat(resource["quantity"]) if quantity <= 0 { continue } priceText, _ := resource["price"].(string) total += quantity * readUnitPrice(priceText) } return roundMoney(total) } func readSummaryFloat(value any) float64 { switch current := value.(type) { case float64: return current case int: return float64(current) case int64: return float64(current) case json.Number: parsed, err := current.Float64() if err != nil { return 0 } return parsed case string: parsed, err := strconv.ParseFloat(strings.TrimSpace(current), 64) if err != nil { return 0 } return parsed default: return 0 } } func readUnitPrice(priceText string) float64 { numbers := priceNumberPattern.FindAllString(priceText, -1) if len(numbers) == 0 { return 0 } amount, err := strconv.ParseFloat(numbers[0], 64) if err != nil { return 0 } if len(numbers) >= 2 { count, err := strconv.ParseFloat(numbers[1], 64) if err == nil && count > 0 { return amount / count } } return amount } func readFireLevel(summary map[string]any) (int, bool) { if summary == nil { return 0, false } value, ok := summary["fire_level"] if !ok { return 0, false } switch current := value.(type) { case float64: return int(current), true case int: return current, true case string: parsed, err := strconv.Atoi(strings.TrimSpace(current)) return parsed, err == nil default: return 0, false } } func hasScreenshotURL(urls []string) bool { for _, url := range urls { if strings.TrimSpace(url) != "" { return true } } return false } func (s *Service) reviewRequired() (bool, error) { if s.config == nil { return false, nil } value, err := s.config.FindValue(reviewRequiredConfigKey) if err != nil { return false, err } required, err := strconv.ParseBool(strings.TrimSpace(value)) if err != nil { return false, nil } return required, nil } func (s *Service) publishRules() (publishRules, error) { rules := publishRules{FireLevelMin: defaultFireLevelMin} if s.config == nil { return rules, nil } value, err := s.config.FindValue(publishOptionsConfigKey) if err != nil { return rules, err } var raw struct { FireLevelMin int `json:"fire_level_min"` } if err := json.Unmarshal([]byte(value), &raw); err != nil { return rules, nil } if raw.FireLevelMin > 0 { rules.FireLevelMin = raw.FireLevelMin } return rules, nil }