5 分钟发布一次
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="listingPublishCooldownConfig" class="publish-config-panel">
|
||||
<div class="publish-config-main">
|
||||
<div>
|
||||
<p class="eyebrow">Publish Guard</p>
|
||||
<h2>发布频率限制</h2>
|
||||
<span>限制同一用户新增发布账号的间隔,默认 5 分钟;填 0 可关闭限制。</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="openEdit(listingPublishCooldownConfig)">
|
||||
编辑限制
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="publish-stat-grid home-stat-grid">
|
||||
<div class="publish-stat">
|
||||
<strong>{{ formatPublishCooldown(listingPublishCooldownConfig.value) }}</strong>
|
||||
<span>当前间隔</span>
|
||||
</div>
|
||||
<div class="publish-stat">
|
||||
<strong>用户维度</strong>
|
||||
<span>每个用户单独计算</span>
|
||||
</div>
|
||||
<div class="publish-stat">
|
||||
<strong>新增发布</strong>
|
||||
<span>不限制编辑和下架</span>
|
||||
</div>
|
||||
<div class="publish-stat">
|
||||
<strong>键</strong>
|
||||
<span>{{ listingPublishCooldownConfig.key }}</span>
|
||||
</div>
|
||||
<div class="publish-stat">
|
||||
<strong>更新</strong>
|
||||
<span>{{ formatHomeConfigStatus(listingPublishCooldownConfig, '未初始化') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="salePriceConfig" class="publish-config-panel">
|
||||
<div class="publish-config-main">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user