5 分钟发布一次

This commit is contained in:
yml2213
2026-06-27 22:26:37 +08:00
parent 3abd513543
commit fed7d6af71
10 changed files with 261 additions and 9 deletions
@@ -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)
}
+34 -2
View File
@@ -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
+22 -5
View File
@@ -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)
}