diff --git a/backend/internal/modules/listing/handler.go b/backend/internal/modules/listing/handler.go index 6afb94b..50ce2c1 100644 --- a/backend/internal/modules/listing/handler.go +++ b/backend/internal/modules/listing/handler.go @@ -313,6 +313,10 @@ func writeListingError(c *gin.Context, err error) { response.BadRequest(c, "哈夫币数量不正确") case errors.Is(err, ErrMissingScreenshot): response.BadRequest(c, "请至少上传一张账号截图") + case isFireLevelTooLow(err): + var levelErr FireLevelTooLowError + errors.As(err, &levelErr) + response.BadRequest(c, "烽火等级低于"+strconv.Itoa(levelErr.Min)+"级的号无法发布") case errors.Is(err, ErrInvalidInput): response.BadRequest(c, "发布信息不符合规则") case errors.Is(err, ErrListingLocked): @@ -323,3 +327,8 @@ func writeListingError(c *gin.Context, err error) { response.Error(c, http.StatusInternalServerError, "internal_error", "发布服务暂时不可用") } } + +func isFireLevelTooLow(err error) bool { + var levelErr FireLevelTooLowError + return errors.As(err, &levelErr) +} diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go index f2e39f0..8204424 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -1,6 +1,7 @@ package listing import ( + "encoding/json" "errors" "strconv" "strings" @@ -27,7 +28,19 @@ type ConfigReader interface { FindValue(key string) (string, error) } -const reviewRequiredConfigKey = "listing.review_required" +const ( + reviewRequiredConfigKey = "listing.review_required" + publishOptionsConfigKey = "listing.publish_options" + defaultFireLevelMin = 38 +) + +type FireLevelTooLowError struct { + Min int +} + +func (e FireLevelTooLowError) Error() string { + return "fire level too low" +} func NewService(repo *Repository, config ConfigReader) *Service { return &Service{repo: repo, config: config} @@ -37,7 +50,11 @@ func (s *Service) Create(ownerID uint64, req CreateRequest) (*ListingDTO, error) if s.repo == nil { return nil, ErrDependencyUnavailable } - if err := validateRequest(req); err != nil { + rules, err := s.publishRules() + if err != nil { + return nil, err + } + if err := validateRequest(req, rules); err != nil { return nil, err } reviewRequired, err := s.reviewRequired() @@ -51,7 +68,11 @@ func (s *Service) Update(ownerID uint64, id uint64, req UpdateRequest) (*Listing if s.repo == nil { return nil, ErrDependencyUnavailable } - if err := validateRequest(req); err != nil { + rules, err := s.publishRules() + if err != nil { + return nil, err + } + if err := validateRequest(req, rules); err != nil { return nil, err } reviewRequired, err := s.reviewRequired() @@ -165,7 +186,11 @@ func (s *Service) FindMine(ownerID uint64, id uint64) (*ListingDTO, error) { return s.repo.FindMine(ownerID, id) } -func validateRequest(req CreateRequest) error { +type publishRules struct { + FireLevelMin int +} + +func validateRequest(req CreateRequest, rules publishRules) error { if strings.TrimSpace(req.Title) == "" { return ErrMissingTitle } @@ -184,9 +209,33 @@ func validateRequest(req CreateRequest) error { if !hasScreenshotURL(req.ScreenshotURLS) { return ErrMissingScreenshot } + if fireLevel, ok := readFireLevel(req.AssetSummary); ok && fireLevel < rules.FireLevelMin { + return FireLevelTooLowError{Min: rules.FireLevelMin} + } return nil } +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) != "" { @@ -210,3 +259,24 @@ func (s *Service) reviewRequired() (bool, error) { } 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 +} diff --git a/backend/internal/modules/systemconfig/dto.go b/backend/internal/modules/systemconfig/dto.go index 1404c60..12c4f05 100644 --- a/backend/internal/modules/systemconfig/dto.go +++ b/backend/internal/modules/systemconfig/dto.go @@ -49,6 +49,7 @@ type PublishOptionsDTO struct { ScreenshotSlots []PublishScreenshotSlot `json:"screenshot_slots"` BanRecordOptions []string `json:"ban_record_options"` BanEvidenceOptions []string `json:"ban_evidence_options"` + FireLevelMin int `json:"fire_level_min"` PriceConfig PublishPriceConfig `json:"price_config"` RatioConfig PublishRatioConfig `json:"ratio_config"` } diff --git a/backend/internal/modules/systemconfig/publish_options.go b/backend/internal/modules/systemconfig/publish_options.go index 455b549..0ac6e29 100644 --- a/backend/internal/modules/systemconfig/publish_options.go +++ b/backend/internal/modules/systemconfig/publish_options.go @@ -81,6 +81,7 @@ func DefaultPublishOptions() PublishOptionsDTO { }, BanRecordOptions: []string{"无封禁记录", "有封禁记录"}, BanEvidenceOptions: []string{"有封禁记录"}, + FireLevelMin: 38, PriceConfig: PublishPriceConfig{ DepositPlaceholder: "温馨提示:本押金用于保障账号安全。若买家在租用期间,使用非纯币类道具/资源(如消耗品、材料等)或导致账号被封禁,相关损失将直接从押金中扣除进行赔付。押金过高会延长出租等待时间(请自行衡量)感谢理解。", PricePlaceholder: "填写币数、保险、体力和负重后自动计算", diff --git a/backend/internal/modules/systemconfig/service.go b/backend/internal/modules/systemconfig/service.go index f555f19..76293b7 100644 --- a/backend/internal/modules/systemconfig/service.go +++ b/backend/internal/modules/systemconfig/service.go @@ -193,6 +193,9 @@ func normalizePublishOptions(options *PublishOptionsDTO) { if len(options.BanEvidenceOptions) == 0 { options.BanEvidenceOptions = defaults.BanEvidenceOptions } + if options.FireLevelMin <= 0 { + options.FireLevelMin = defaults.FireLevelMin + } if options.PriceConfig.DepositPlaceholder == "" { options.PriceConfig.DepositPlaceholder = defaults.PriceConfig.DepositPlaceholder } diff --git a/frontend/src/api/listingOptions.ts b/frontend/src/api/listingOptions.ts index 84a13d6..f2d7ee2 100644 --- a/frontend/src/api/listingOptions.ts +++ b/frontend/src/api/listingOptions.ts @@ -71,6 +71,7 @@ export interface ListingPublishOptions { screenshot_slots: PublishScreenshotSlot[] ban_record_options: string[] ban_evidence_options: string[] + fire_level_min: number price_config: PublishPriceConfig ratio_config: PublishRatioConfig } @@ -94,6 +95,7 @@ export const emptyListingPublishOptions: ListingPublishOptions = { screenshot_slots: [], ban_record_options: [], ban_evidence_options: [], + fire_level_min: 38, price_config: { deposit_placeholder: '', price_placeholder: '', @@ -125,6 +127,7 @@ export function mergeListingPublishOptions(options?: Partial 0 ? parsed : fallback +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } diff --git a/frontend/src/views/admin/AdminSystemConfigsView.vue b/frontend/src/views/admin/AdminSystemConfigsView.vue index 15a52a2..652c260 100644 --- a/frontend/src/views/admin/AdminSystemConfigsView.vue +++ b/frontend/src/views/admin/AdminSystemConfigsView.vue @@ -543,6 +543,15 @@ function readError(error: unknown, fallback: string) { +
+
+ 发布规则 +
+ + + +
+
押金与价格提示 diff --git a/frontend/src/views/mobile/MobileSellerListingCreateView.vue b/frontend/src/views/mobile/MobileSellerListingCreateView.vue index edd248e..b00bfe9 100644 --- a/frontend/src/views/mobile/MobileSellerListingCreateView.vue +++ b/frontend/src/views/mobile/MobileSellerListingCreateView.vue @@ -112,6 +112,10 @@ const regionOptions = computed(() => publishOptions.value.region_options); const banRecordOptions = computed(() => publishOptions.value.ban_record_options); const banEvidenceOptions = computed(() => publishOptions.value.ban_evidence_options); const priceConfig = computed(() => publishOptions.value.price_config); +const fireLevelMin = computed(() => publishOptions.value.fire_level_min || 38); +const fireLevelPlaceholder = computed( + () => `等级低于${fireLevelMin.value}级的号无法发布` +); const ratioConfig = computed(() => publishOptions.value.ratio_config); const skinGroups = computed(() => publishOptions.value.skin_groups); const quantityItems = computed(() => publishOptions.value.quantity_items); @@ -394,7 +398,9 @@ function validateForm() { if (coinMAmount.value <= 0) return "请填写哈夫币/M"; if (!form.rank_level) return "请选择段位"; if (!form.fire_level) return "请填写烽火等级"; - if (Number(form.fire_level) < 38) return "烽火等级低于38级的号无法发布"; + if (Number(form.fire_level) < fireLevelMin.value) { + return `烽火等级低于${fireLevelMin.value}级的号无法发布`; + } if (!form.season_insurance) return "请选择赛季保险"; if (!form.stamina_level) return "请选择体力等级"; if (!form.load_level) return "请选择负重等级"; @@ -642,7 +648,7 @@ function readError(error: unknown, fallback: string) { label="烽火等级" type="digit" required - placeholder="等级低于38级的号无法发布" + :placeholder="fireLevelPlaceholder" class="publish-field" @update:model-value="handleFireLevelInput" />