From fed7d6af71843b105c824755e49686bf1fb89e53 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 27 Jun 2026 22:26:37 +0800 Subject: [PATCH] =?UTF-8?q?5=20=E5=88=86=E9=92=9F=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E4=B8=80=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/modules/listing/handler_error.go | 26 ++++++ backend/internal/modules/listing/mutation.go | 36 +++++++- backend/internal/modules/listing/service.go | 27 ++++-- .../modules/listing/service_config.go | 19 +++++ .../modules/listing/service_mutation.go | 6 +- .../internal/modules/listing/service_test.go | 83 ++++++++++++++++++- .../modules/systemconfig/repository.go | 2 + .../modules/systemconfig/service_update.go | 8 ++ .../000017_listing_publish_cooldown.sql | 16 ++++ .../admin/views/AdminSystemConfigsView.vue | 47 +++++++++++ 10 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 backend/migrations/000017_listing_publish_cooldown.sql diff --git a/backend/internal/modules/listing/handler_error.go b/backend/internal/modules/listing/handler_error.go index 1ee0e27..5a68bf3 100644 --- a/backend/internal/modules/listing/handler_error.go +++ b/backend/internal/modules/listing/handler_error.go @@ -2,8 +2,10 @@ package listing import ( "errors" + "fmt" "net/http" "strconv" + "time" "hfb_sys/backend/pkg/response" @@ -56,6 +58,8 @@ func writeListingError(c *gin.Context, err error) { response.BadRequest(c, "发布信息不符合规则") case errors.Is(err, ErrListingLocked): response.Error(c, http.StatusConflict, "listing_locked", "当前发布不可修改") + case errors.Is(err, ErrPublishCooldown): + response.Error(c, http.StatusTooManyRequests, "publish_cooldown", publishCooldownMessage(err)) case IsNotFound(err): response.Error(c, http.StatusNotFound, "not_found", "发布不存在") default: @@ -72,3 +76,25 @@ func isUploadValidationError(err error) bool { var validationErr UploadValidationError return errors.As(err, &validationErr) } + +func publishCooldownMessage(err error) string { + var cooldownErr PublishCooldownError + if !errors.As(err, &cooldownErr) { + return "发布太频繁,请稍后再试" + } + cooldownMinutes := ceilDuration(cooldownErr.Cooldown, time.Minute) + if cooldownMinutes <= 0 { + return "发布太频繁,请稍后再试" + } + if cooldownErr.Remaining < time.Minute { + return fmt.Sprintf("发布太频繁,每%d分钟只能发布一个账号,请%d秒后再试", cooldownMinutes, ceilDuration(cooldownErr.Remaining, time.Second)) + } + return fmt.Sprintf("发布太频繁,每%d分钟只能发布一个账号,请%d分钟后再试", cooldownMinutes, ceilDuration(cooldownErr.Remaining, time.Minute)) +} + +func ceilDuration(value time.Duration, unit time.Duration) int { + if value <= 0 || unit <= 0 { + return 0 + } + return int((value + unit - time.Nanosecond) / unit) +} diff --git a/backend/internal/modules/listing/mutation.go b/backend/internal/modules/listing/mutation.go index fa766ee..5df0ac6 100644 --- a/backend/internal/modules/listing/mutation.go +++ b/backend/internal/modules/listing/mutation.go @@ -11,12 +11,17 @@ import ( "gorm.io/datatypes" "gorm.io/gorm" + "gorm.io/gorm/clause" ) -func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateRequest, reviewRequired bool) (*ListingDTO, error) { +func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateRequest, reviewRequired bool, publishCooldown time.Duration) (*ListingDTO, error) { var dto *ListingDTO err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - listingNo, err := r.nextListingNo(tx, time.Now()) + now := time.Now() + if err := r.ensureCreateCooldown(tx, ownerID, publishCooldown, now); err != nil { + return err + } + listingNo, err := r.nextListingNo(tx, now) if err != nil { return err } @@ -77,6 +82,33 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque return dto, err } +func (r *Repository) ensureCreateCooldown(tx *gorm.DB, ownerID uint64, cooldown time.Duration, now time.Time) error { + if cooldown <= 0 { + return nil + } + + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&user, ownerID).Error; err != nil { + return err + } + + var latest model.RentalListing + err := tx.Where("owner_id = ?", ownerID). + Order("created_at DESC, id DESC"). + First(&latest).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + remaining := latest.CreatedAt.Add(cooldown).Sub(now) + if remaining > 0 { + return PublishCooldownError{Cooldown: cooldown, Remaining: remaining} + } + return nil +} + type externalUploadCreate struct { UploaderName string ClientUploadTime *time.Time diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go index 15ac1ea..16154a3 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -4,12 +4,14 @@ import ( "context" "errors" "strings" + "time" ) var ( ErrDependencyUnavailable = errors.New("dependency unavailable") ErrInvalidInput = errors.New("invalid listing input") ErrListingLocked = errors.New("listing locked") + ErrPublishCooldown = errors.New("listing publish cooldown") ErrMissingTitle = errors.New("missing listing title") ErrMissingServerRegion = errors.New("missing server region") ErrInvalidPrice = errors.New("invalid listing price") @@ -38,13 +40,28 @@ type ConfigReader interface { } const ( - reviewRequiredConfigKey = "listing.review_required" - publishOptionsConfigKey = "listing.publish_options" - defaultFireLevelMin = 38 - maxExternalUploadItems = 10 - defaultUploadScreenshot = "/api/listings/default-upload-screenshot" + reviewRequiredConfigKey = "listing.review_required" + publishOptionsConfigKey = "listing.publish_options" + publishCooldownMinutesKey = "listing.publish_cooldown_minutes" + defaultFireLevelMin = 38 + defaultPublishCooldownMinutes = 5 + maxExternalUploadItems = 10 + defaultUploadScreenshot = "/api/listings/default-upload-screenshot" ) +type PublishCooldownError struct { + Cooldown time.Duration + Remaining time.Duration +} + +func (e PublishCooldownError) Error() string { + return ErrPublishCooldown.Error() +} + +func (e PublishCooldownError) Is(target error) bool { + return target == ErrPublishCooldown +} + type FireLevelTooLowError struct { Min int } diff --git a/backend/internal/modules/listing/service_config.go b/backend/internal/modules/listing/service_config.go index 0982ae2..061fe91 100644 --- a/backend/internal/modules/listing/service_config.go +++ b/backend/internal/modules/listing/service_config.go @@ -5,6 +5,7 @@ import ( "encoding/json" "strconv" "strings" + "time" ) func (s *Service) reviewRequired(ctx context.Context) (bool, error) { @@ -42,3 +43,21 @@ func (s *Service) publishRules(ctx context.Context) (publishRules, error) { } return rules, nil } + +func (s *Service) publishCooldown(ctx context.Context) (time.Duration, error) { + if s.config == nil { + return time.Duration(defaultPublishCooldownMinutes) * time.Minute, nil + } + value, err := s.config.FindValue(ctx, publishCooldownMinutesKey) + if err != nil { + return 0, err + } + minutes, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return time.Duration(defaultPublishCooldownMinutes) * time.Minute, nil + } + if minutes <= 0 { + return 0, nil + } + return time.Duration(minutes) * time.Minute, nil +} diff --git a/backend/internal/modules/listing/service_mutation.go b/backend/internal/modules/listing/service_mutation.go index fd71d84..a7ed059 100644 --- a/backend/internal/modules/listing/service_mutation.go +++ b/backend/internal/modules/listing/service_mutation.go @@ -21,11 +21,15 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest) if err := validateRequiredOnlineTime(req); err != nil { return nil, err } + cooldown, err := s.publishCooldown(ctx) + if err != nil { + return nil, err + } reviewRequired, err := s.reviewRequired(ctx) if err != nil { return nil, err } - return s.repo.Create(ctx, ownerID, req, reviewRequired) + return s.repo.Create(ctx, ownerID, req, reviewRequired, cooldown) } func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req UpdateRequest) (*ListingDTO, error) { diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index d457a2e..0160371 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -1,13 +1,22 @@ package listing import ( + "context" "encoding/json" + "errors" "testing" + "time" "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" ) +type stubConfigReader map[string]string + +func (s stubConfigReader) FindValue(_ context.Context, key string) (string, error) { + return s[key], nil +} + func TestConsumableValueOnlyCountsChargedResources(t *testing.T) { value := consumableValue(map[string]any{ "resources": []any{ @@ -108,6 +117,62 @@ func TestCreateRequiresPublishAgreements(t *testing.T) { } } +func TestPublishCooldownUsesConfiguredMinutes(t *testing.T) { + service := NewService(nil, stubConfigReader{publishCooldownMinutesKey: "2"}) + + cooldown, err := service.publishCooldown(t.Context()) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if cooldown != 2*time.Minute { + t.Fatalf("expected 2m cooldown, got %s", cooldown) + } +} + +func TestPublishCooldownCanBeDisabled(t *testing.T) { + service := NewService(nil, stubConfigReader{publishCooldownMinutesKey: "0"}) + + cooldown, err := service.publishCooldown(t.Context()) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if cooldown != 0 { + t.Fatalf("expected disabled cooldown, got %s", cooldown) + } +} + +func TestRepositoryCreateRejectsRecentOwnerPublish(t *testing.T) { + db := database.NewTestDB() + if err := db.AutoMigrate(&model.User{}, &model.GameAccount{}, &model.RentalListing{}); err != nil { + t.Fatalf("failed to migrate test db: %v", err) + } + user := model.User{Phone: "18800000000", RealnameStatus: "verified", Status: "active"} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + latestCreatedAt := time.Now().Add(-2 * time.Minute) + if err := db.Create(&model.RentalListing{ + ListingNo: "202606270001", + AccountID: 1, + OwnerID: user.ID, + Status: "published", + CreatedAt: latestCreatedAt, + ReviewStatus: "approved", + }).Error; err != nil { + t.Fatalf("failed to create latest listing: %v", err) + } + + repo := NewRepository(db, nil) + _, err := repo.Create(t.Context(), user.ID, validCreateRequest(), false, 5*time.Minute) + var cooldownErr PublishCooldownError + if !errors.As(err, &cooldownErr) { + t.Fatalf("expected PublishCooldownError, got %v", err) + } + if cooldownErr.Remaining <= 0 { + t.Fatalf("expected positive remaining cooldown, got %s", cooldownErr.Remaining) + } +} + func TestRepositoryUpdateAllowsOfflineListingResubmit(t *testing.T) { db := database.NewTestDB() if err := db.AutoMigrate(&model.GameAccount{}, &model.RentalListing{}); err != nil { @@ -127,7 +192,7 @@ func TestRepositoryUpdateAllowsOfflineListingResubmit(t *testing.T) { "online_time": map[string]any{"start": "09:00", "end": "23:00"}, }, } - created, err := repo.Create(t.Context(), 1, req, false) + created, err := repo.Create(t.Context(), 1, req, false, 0) if err != nil { t.Fatalf("failed to create listing: %v", err) } @@ -148,6 +213,22 @@ func TestRepositoryUpdateAllowsOfflineListingResubmit(t *testing.T) { } } +func validCreateRequest() CreateRequest { + return CreateRequest{ + Title: "测试账号", + ServerRegion: "QQ", + LoginPlatform: "QQ账号密码", + RankLevel: "黑鹰", + HafCoinAmount: 100000000, + PriceCent: 10000, + DepositAmountCent: 50000, + ScreenshotURLS: []string{"https://example.com/a.png"}, + AssetSummary: map[string]any{ + "online_time": map[string]any{"start": "09:00", "end": "23:00"}, + }, + } +} + func TestApplySellerListingPriceUsesSellerTotalPrice(t *testing.T) { item := &ListingDTO{ PriceCent: 23800, diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index 0f51bde..82c1fd9 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -32,6 +32,7 @@ var defaultConfigs = []defaultConfig{ {Key: "order.pending_payment_timeout_minutes", Value: "15", Description: "订单待支付超时取消分钟数"}, {Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后结账宽限分钟数"}, {Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"}, + {Key: "listing.publish_cooldown_minutes", Value: "5", Description: "同一用户两次新增发布账号的最小间隔分钟数(0 表示关闭)"}, {Key: listingPublishAgreementsConfigKey, Value: defaultListingPublishAgreementsConfigValue(), Description: "发布账号前协议配置 JSON"}, {Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"}, {Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), Description: "租后须知配置 JSON"}, @@ -60,6 +61,7 @@ var adminVisibleConfigKeys = []string{ "integration.paddle_ocr_model", "integration.paddle_ocr_token", "listing.publish_agreements", + "listing.publish_cooldown_minutes", "listing.publish_options", "listing.review_required", "listing.sale_price_config", diff --git a/backend/internal/modules/systemconfig/service_update.go b/backend/internal/modules/systemconfig/service_update.go index 317a3a8..0873097 100644 --- a/backend/internal/modules/systemconfig/service_update.go +++ b/backend/internal/modules/systemconfig/service_update.go @@ -2,6 +2,8 @@ package systemconfig import ( "context" + "strconv" + "strings" ) func (s *Service) Update(ctx context.Context, actorID uint64, key string, req UpdateRequest, meta AuditMeta) (*ConfigDTO, error) { @@ -11,5 +13,11 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up if key == "" || (req.Value == "" && key != "integration.paddle_ocr_token") { return nil, ErrInvalidConfig } + if key == "listing.publish_cooldown_minutes" { + minutes, err := strconv.Atoi(strings.TrimSpace(req.Value)) + if err != nil || minutes < 0 { + return nil, ErrInvalidConfig + } + } return s.repo.Update(ctx, actorID, key, req, meta) } diff --git a/backend/migrations/000017_listing_publish_cooldown.sql b/backend/migrations/000017_listing_publish_cooldown.sql new file mode 100644 index 0000000..675abbf --- /dev/null +++ b/backend/migrations/000017_listing_publish_cooldown.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin + +INSERT INTO system_configs (`key`, `value`, description) VALUES + ('listing.publish_cooldown_minutes', '5', '同一用户两次新增发布账号的最小间隔分钟数(0 表示关闭)') +ON DUPLICATE KEY UPDATE + description = VALUES(description); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DELETE FROM system_configs WHERE `key` = 'listing.publish_cooldown_minutes'; + +-- +goose StatementEnd diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.vue b/frontend/src/features/admin/views/AdminSystemConfigsView.vue index e375f39..97035b7 100644 --- a/frontend/src/features/admin/views/AdminSystemConfigsView.vue +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.vue @@ -81,6 +81,9 @@ const publishConfig = computed( const salePriceConfig = computed( () => configs.value.find(item => item.key === 'listing.sale_price_config') || null ) +const listingPublishCooldownConfig = computed( + () => configs.value.find(item => item.key === 'listing.publish_cooldown_minutes') || null +) const homeAnnouncementsConfig = computed( () => configs.value.find(item => item.key === 'mobile.home_announcements') || null ) @@ -128,6 +131,7 @@ const regularConfigs = computed(() => item => item.key !== 'listing.publish_options' && item.key !== 'listing.sale_price_config' && + item.key !== 'listing.publish_cooldown_minutes' && item.key !== 'mobile.home_announcements' && item.key !== 'mobile.home_banners' && item.key !== 'listing.publish_agreements' && @@ -341,6 +345,14 @@ function formatHomeConfigStatus(row: SystemConfig | null, fallback: string) { return formatDateTime(row.updated_at, fallback) } +function formatPublishCooldown(value: string) { + const trimmed = value.trim() + if (!/^\d+$/.test(trimmed)) return '配置异常' + const minutes = Number(trimmed) + if (minutes === 0) return '已关闭' + return `${minutes} 分钟` +} + function formatConfigValue(row: SystemConfig) { if (row.key.includes('token')) { return row.value.trim() ? '已配置' : '未配置' @@ -398,6 +410,41 @@ function formatConfigValue(row: SystemConfig) { +
+
+
+

Publish Guard

+

发布频率限制

+ 限制同一用户新增发布账号的间隔,默认 5 分钟;填 0 可关闭限制。 +
+ + 编辑限制 + +
+
+
+ {{ formatPublishCooldown(listingPublishCooldownConfig.value) }} + 当前间隔 +
+
+ 用户维度 + 每个用户单独计算 +
+
+ 新增发布 + 不限制编辑和下架 +
+
+ + {{ listingPublishCooldownConfig.key }} +
+
+ 更新 + {{ formatHomeConfigStatus(listingPublishCooldownConfig, '未初始化') }} +
+
+
+