diff --git a/backend/internal/model/listing.go b/backend/internal/model/listing.go index 49c6245..8b30a68 100644 --- a/backend/internal/model/listing.go +++ b/backend/internal/model/listing.go @@ -43,6 +43,9 @@ type RentalListing struct { ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"` DistributionSiteID *uint64 `gorm:"column:distribution_site_id;index" json:"distribution_site_id,omitempty"` SourceChannel string `gorm:"size:64;not null;default:''" json:"-"` + IsPremium bool `gorm:"not null;default:false" json:"is_premium"` + CompensationPlan string `gorm:"size:16;not null;default:'normal'" json:"compensation_plan"` + InsuranceFeeBps int `gorm:"not null;default:0" json:"insurance_fee_bps"` ReviewReason string `gorm:"size:255;not null;default:''" json:"review_reason"` PublishedAt *time.Time `json:"published_at"` CreatedAt time.Time `json:"created_at"` diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index 9016ecc..4b58ffb 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -53,6 +53,10 @@ type RentalOrder struct { DistributionSiteID *uint64 `gorm:"index" json:"distribution_site_id,omitempty"` DistributionMode string `gorm:"size:32;not null;default:'';index" json:"distribution_mode"` DistributionRuleVersionID *uint64 `gorm:"index" json:"distribution_rule_version_id,omitempty"` + IsPremium bool `gorm:"not null;default:false" json:"is_premium"` + CompensationPlan string `gorm:"size:16;not null;default:'normal'" json:"compensation_plan"` + InsuranceFeeBps int `gorm:"not null;default:0" json:"insurance_fee_bps"` + InsuranceFeeCent int64 `gorm:"not null;default:0" json:"insurance_fee_cent"` Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"` HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"` HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"` diff --git a/backend/internal/model/order_checkout.go b/backend/internal/model/order_checkout.go index 9443c05..6e5bd34 100644 --- a/backend/internal/model/order_checkout.go +++ b/backend/internal/model/order_checkout.go @@ -17,6 +17,8 @@ type OrderCheckout struct { RentAmountCent int64 `gorm:"not null;default:0" json:"-"` OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"` PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"` + InsuranceFeeBps int `gorm:"not null;default:0" json:"-"` + InsuranceFeeCent int64 `gorm:"not null;default:0" json:"-"` DepositAmountCent int64 `gorm:"not null;default:0" json:"-"` PureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"` PureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"` diff --git a/backend/internal/model/payment.go b/backend/internal/model/payment.go index 5192887..71cccca 100644 --- a/backend/internal/model/payment.go +++ b/backend/internal/model/payment.go @@ -11,6 +11,7 @@ type PaymentOrder struct { PaymentNo string `gorm:"size:64;not null;uniqueIndex" json:"payment_no"` OrderID uint64 `gorm:"not null;index" json:"order_id"` OrderNo string `gorm:"size:64;not null;index" json:"order_no"` + BizID uint64 `gorm:"not null;default:0;index:idx_payment_orders_user_biz,priority:3" json:"biz_id"` UserID uint64 `gorm:"not null;index" json:"user_id"` PaymentConfigID uint64 `gorm:"not null;default:0;index" json:"payment_config_id"` Provider string `gorm:"size:32;not null" json:"provider"` diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index ffc9315..143bcca 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -3,18 +3,22 @@ package model import "time" type User struct { - ID uint64 `gorm:"primaryKey" json:"id"` - Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"` - PasswordHash string `gorm:"size:255;not null;default:''" json:"-"` - Nickname string `gorm:"size:64;not null;default:''" json:"nickname"` - AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"` - RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"` - RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"` - CreditScore int `gorm:"not null;default:100" json:"credit_score"` - DepositFreeQuotaCent int64 `gorm:"not null;default:0" json:"-"` - RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"` - RenterGrowthLevel string `gorm:"size:32;not null;default:'normal';index:idx_users_renter_growth_level,priority:1" json:"renter_growth_level"` - Status string `gorm:"size:32;not null;default:'active'" json:"status"` + ID uint64 `gorm:"primaryKey" json:"id"` + Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"` + PasswordHash string `gorm:"size:255;not null;default:''" json:"-"` + Nickname string `gorm:"size:64;not null;default:''" json:"nickname"` + AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"` + RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"` + RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"` + CreditScore int `gorm:"not null;default:100" json:"credit_score"` + DepositFreeQuotaCent int64 `gorm:"not null;default:0" json:"-"` + RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"` + RenterGrowthLevel string `gorm:"size:32;not null;default:'normal';index:idx_users_renter_growth_level,priority:1" json:"renter_growth_level"` + Status string `gorm:"size:32;not null;default:'active'" json:"status"` + QualityOwnerStatus string `gorm:"size:16;not null;default:'none';index:idx_users_quality_owner_status" json:"quality_owner_status"` + QualityOwnerDepositCent int64 `gorm:"not null;default:0" json:"quality_owner_deposit_cent"` + QualityOwnerDepositSource string `gorm:"size:16;not null;default:''" json:"quality_owner_deposit_source"` + QualityOwnerActivatedAt *time.Time `json:"quality_owner_activated_at"` // 会话版本:递增后该用户已签发的全部令牌立即失效。 // 默认值对齐迁移 000052 的 users.token_version DEFAULT 0。 TokenVersion int64 `gorm:"not null;default:0" json:"-"` diff --git a/backend/internal/modules/adminuser/dto.go b/backend/internal/modules/adminuser/dto.go index 66f0d04..e591adb 100644 --- a/backend/internal/modules/adminuser/dto.go +++ b/backend/internal/modules/adminuser/dto.go @@ -20,6 +20,10 @@ type UserDTO struct { RenterGrowthLevelName string `json:"renter_growth_level_name"` AvailableBalanceCent int64 `json:"available_balance_cent"` FrozenBalanceCent int64 `json:"frozen_balance_cent"` + QualityOwnerStatus string `json:"quality_owner_status"` + QualityOwnerDepositCent int64 `json:"quality_owner_deposit_cent"` + QualityOwnerDepositSource string `json:"quality_owner_deposit_source"` + QualityOwnerActivatedAt *time.Time `json:"quality_owner_activated_at"` Status string `json:"status"` OrderCount int64 `json:"order_count"` ListingCount int64 `json:"listing_count"` @@ -65,6 +69,7 @@ type ListQuery struct { Keyword string Status string HasDepositFreeQuota bool + QualityOwnerOnly bool } type PaginatedResult struct { diff --git a/backend/internal/modules/adminuser/handler.go b/backend/internal/modules/adminuser/handler.go index a5c9d80..05f339f 100644 --- a/backend/internal/modules/adminuser/handler.go +++ b/backend/internal/modules/adminuser/handler.go @@ -41,6 +41,7 @@ func (h *Handler) List(c *gin.Context) { Keyword: strings.TrimSpace(c.Query("keyword")), Status: strings.TrimSpace(c.Query("status")), HasDepositFreeQuota: parseBoolQuery(c.Query("has_deposit_free_quota")), + QualityOwnerOnly: parseBoolQuery(c.Query("quality_owner_only")), } result, err := h.service.List(c.Request.Context(), page, pageSize, query) if err != nil { diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index 59e0ab3..ebf8cbf 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -106,6 +106,9 @@ func applyUserFilter(tx *gorm.DB, query ListQuery, growthConfig rentergrowth.Con tx = tx.Where("u.deposit_free_quota_cent > 0") } } + if query.QualityOwnerOnly { + tx = tx.Where("u.quality_owner_status = ?", "active") + } if keyword := strings.TrimSpace(query.Keyword); keyword != "" { if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) { // 11 位手机号:命中 users(phone) 唯一索引 @@ -563,6 +566,10 @@ func (row userRow) toDTO(growthConfig rentergrowth.Config) UserDTO { RenterGrowthLevelName: level.Name, AvailableBalanceCent: row.AvailableBalanceCent, FrozenBalanceCent: row.FrozenBalanceCent, + QualityOwnerStatus: row.QualityOwnerStatus, + QualityOwnerDepositCent: row.QualityOwnerDepositCent, + QualityOwnerDepositSource: row.QualityOwnerDepositSource, + QualityOwnerActivatedAt: row.QualityOwnerActivatedAt, Status: row.Status, OrderCount: row.OrderCount, ListingCount: row.ListingCount, diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 057645f..92f38b3 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -15,6 +15,7 @@ type ListingDTO struct { OwnerID uint64 `json:"owner_id"` OwnerPhone string `json:"owner_phone,omitempty"` OwnerNickname string `json:"owner_nickname,omitempty"` + OwnerIsQualityOwner bool `json:"owner_is_quality_owner"` SourceChannel string `json:"source_channel,omitempty"` IsExternalUpload bool `json:"is_external_upload"` Title string `json:"title"` @@ -41,6 +42,9 @@ type ListingDTO struct { DistributionSiteID *uint64 `json:"distribution_site_id,omitempty"` DistributionSiteCode string `json:"distribution_site_code,omitempty"` DistributionSiteName string `json:"distribution_site_name,omitempty"` + IsPremium bool `json:"is_premium"` + CompensationPlan string `json:"compensation_plan"` + InsuranceFeeBps int `json:"insurance_fee_bps"` ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -57,6 +61,9 @@ type CreateRequest struct { ScreenshotURLS []string `json:"screenshot_urls"` PriceCent int64 `json:"price_cent"` DepositAmountCent int64 `json:"deposit_amount_cent"` + IsPremium bool `json:"is_premium"` + CompensationPlan string `json:"compensation_plan" binding:"omitempty,oneof=normal insurance"` + InsuranceFeeBps int `json:"-"` // 服务端按配置和用户资格生成,不接受前端指定。 AgreedVirtualAssetSale bool `json:"agreed_virtual_asset_sale"` AgreedSellerAgreement bool `json:"agreed_seller_agreement"` diff --git a/backend/internal/modules/listing/handler_error.go b/backend/internal/modules/listing/handler_error.go index be4be34..dfa3236 100644 --- a/backend/internal/modules/listing/handler_error.go +++ b/backend/internal/modules/listing/handler_error.go @@ -57,6 +57,8 @@ func writeListingError(c *gin.Context, err error) { response.BadRequest(c, "发布比例必须在参考比例至参考比例加 10 之间") case errors.Is(err, ErrDistributionSiteInvalid): response.BadRequest(c, "分销站点无效或已停用") + case errors.Is(err, ErrPremiumOwnerRequired): + response.Error(c, http.StatusForbidden, "quality_owner_required", "仅优质号主可以发布到极品专区") case isUploadValidationError(err): response.BadRequest(c, err.Error()) case isFireLevelTooLow(err): diff --git a/backend/internal/modules/listing/mutation.go b/backend/internal/modules/listing/mutation.go index 5a97afb..8746afc 100644 --- a/backend/internal/modules/listing/mutation.go +++ b/backend/internal/modules/listing/mutation.go @@ -76,6 +76,9 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque OwnerID: ownerID, PriceCent: priceCent, DepositAmountCent: req.DepositAmountCent, + IsPremium: req.IsPremium, + CompensationPlan: req.CompensationPlan, + InsuranceFeeBps: req.InsuranceFeeBps, Status: listingStatus, ReviewStatus: reviewStatus, PublishedAt: publishedAt, @@ -289,6 +292,9 @@ func (r *Repository) Update(ctx context.Context, ownerID uint64, listingID uint6 listing.PriceCent = normalizedListingPriceCent(req) listing.DepositAmountCent = req.DepositAmountCent + listing.IsPremium = req.IsPremium + listing.CompensationPlan = req.CompensationPlan + listing.InsuranceFeeBps = req.InsuranceFeeBps fromStatus := listing.Status listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired) listing.Status = listingStatus diff --git a/backend/internal/modules/listing/presenter.go b/backend/internal/modules/listing/presenter.go index 3756c05..3206ccc 100644 --- a/backend/internal/modules/listing/presenter.go +++ b/backend/internal/modules/listing/presenter.go @@ -18,6 +18,7 @@ type listingRow struct { Title string OwnerPhone string OwnerNickname string + OwnerIsQualityOwner bool `gorm:"column:owner_is_quality_owner"` DisplaySourceChannel string `gorm:"column:display_source_channel"` IsExternalUpload bool `gorm:"column:is_external_upload"` DistributionSiteCode string `gorm:"column:distribution_site_code"` @@ -171,6 +172,7 @@ func (row listingRow) toDTO() ListingDTO { OwnerID: row.OwnerID, OwnerPhone: row.OwnerPhone, OwnerNickname: row.OwnerNickname, + OwnerIsQualityOwner: row.OwnerIsQualityOwner, SourceChannel: row.DisplaySourceChannel, IsExternalUpload: row.IsExternalUpload, Title: row.Title, @@ -195,6 +197,9 @@ func (row listingRow) toDTO() ListingDTO { DistributionSiteID: row.DistributionSiteID, DistributionSiteCode: row.DistributionSiteCode, DistributionSiteName: row.DistributionSiteName, + IsPremium: row.IsPremium, + CompensationPlan: normalizedCompensationPlan(row.CompensationPlan), + InsuranceFeeBps: row.InsuranceFeeBps, ReviewReason: reviewReason, PublishedAt: row.PublishedAt, CreatedAt: row.CreatedAt, @@ -231,6 +236,9 @@ func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO { SettlementMode: listing.SettlementMode, ManagedAdminID: listing.ManagedAdminID, DistributionSiteID: listing.DistributionSiteID, + IsPremium: listing.IsPremium, + CompensationPlan: normalizedCompensationPlan(listing.CompensationPlan), + InsuranceFeeBps: listing.InsuranceFeeBps, ReviewReason: reviewReason, PublishedAt: listing.PublishedAt, CreatedAt: listing.CreatedAt, @@ -238,6 +246,13 @@ func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO { } } +func normalizedCompensationPlan(plan string) string { + if plan == CompensationPlanInsurance { + return CompensationPlanInsurance + } + return CompensationPlanNormal +} + func normalizedReviewState(status string, reviewStatus string, reviewReason string) (string, string) { if status == "offline" { return "none", reviewReason diff --git a/backend/internal/modules/listing/public_filter.go b/backend/internal/modules/listing/public_filter.go index 38c62ef..8b77417 100644 --- a/backend/internal/modules/listing/public_filter.go +++ b/backend/internal/modules/listing/public_filter.go @@ -185,6 +185,8 @@ func matchesPublicZone(item ListingDTO, zone string) bool { return strings.Contains(item.LoginPlatform, "账密") || strings.Contains(item.LoginPlatform, "账号密码") case "highCoin": return coinMFromListing(item) >= 100 + case "premium": + return item.IsPremium default: return true } @@ -198,9 +200,10 @@ func publicZoneCounts(items []ListingDTO) map[string]int64 { "night": 0, "password": 0, "highCoin": 0, + "premium": 0, } for _, item := range items { - for _, zone := range []string{"sale", "gift", "night", "password", "highCoin"} { + for _, zone := range []string{"sale", "gift", "night", "password", "highCoin", "premium"} { if matchesPublicZone(item, zone) { counts[zone]++ } diff --git a/backend/internal/modules/listing/public_filter_test.go b/backend/internal/modules/listing/public_filter_test.go index a422210..fe63aa4 100644 --- a/backend/internal/modules/listing/public_filter_test.go +++ b/backend/internal/modules/listing/public_filter_test.go @@ -21,6 +21,17 @@ func TestFilterPublicListingsByRatio(t *testing.T) { } } +func TestMatchesPublicZonePremium(t *testing.T) { + premium := ListingDTO{ID: 1, IsPremium: true} + ordinary := ListingDTO{ID: 2} + if !matchesPublicZone(premium, "premium") { + t.Fatal("premium listing should match premium zone") + } + if matchesPublicZone(ordinary, "premium") { + t.Fatal("ordinary listing should not match premium zone") + } +} + func TestFilterPublicListingsRegionMultiSelectRequiresAll(t *testing.T) { items := []ListingDTO{ { diff --git a/backend/internal/modules/listing/public_query.go b/backend/internal/modules/listing/public_query.go index 2e2dac7..664d72f 100644 --- a/backend/internal/modules/listing/public_query.go +++ b/backend/internal/modules/listing/public_query.go @@ -185,7 +185,7 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int var rows []publicZoneRow err := r.db.WithContext(ctx).Table("rental_listings AS l"). - Select("a.login_platform, a.haf_coin_amount, a.asset_summary"). + Select("a.login_platform, a.haf_coin_amount, a.asset_summary, l.is_premium"). Joins("JOIN game_accounts AS a ON a.id = l.account_id"). Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). Scan(&rows).Error @@ -199,6 +199,7 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int "night": 0, "password": 0, "highCoin": 0, + "premium": 0, } for _, row := range rows { summary := decodeAssetSummary(row.AssetSummary) @@ -217,6 +218,9 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int if float64(row.HafCoinAmount)/1000000 >= 100 { counts["highCoin"]++ } + if row.IsPremium { + counts["premium"]++ + } } r.publicZoneCounts = publicZoneCountCache{ Counts: counts, @@ -319,4 +323,5 @@ type publicZoneRow struct { LoginPlatform string HafCoinAmount int64 AssetSummary datatypes.JSON `gorm:"column:asset_summary"` + IsPremium bool `gorm:"column:is_premium"` } diff --git a/backend/internal/modules/listing/quality_owner_entitlement_test.go b/backend/internal/modules/listing/quality_owner_entitlement_test.go new file mode 100644 index 0000000..c8e5d9a --- /dev/null +++ b/backend/internal/modules/listing/quality_owner_entitlement_test.go @@ -0,0 +1,64 @@ +package listing + +import ( + "errors" + "testing" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/qualityowner" +) + +func TestApplyPublishEntitlementsPremiumInsurance(t *testing.T) { + db := database.NewTestDB() + owner := model.User{ + Phone: "13900009311", + RealnameStatus: "verified", + Status: "active", + QualityOwnerStatus: qualityowner.StatusActive, + } + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("创建优质号主失败: %v", err) + } + service := NewService(NewRepository(db, nil), stubConfigReader{ + qualityowner.ConfigKey: qualityowner.DefaultConfigValue(), + }) + + req := CreateRequest{ + IsPremium: true, + CompensationPlan: CompensationPlanInsurance, + } + if err := service.applyPublishEntitlements(t.Context(), owner.ID, &req); err != nil { + t.Fatalf("应用优质号主发布权益失败: %v", err) + } + if req.InsuranceFeeBps != 400 { + t.Fatalf("极品专区保险费率 = %d, want 400", req.InsuranceFeeBps) + } + + ordinary := CreateRequest{CompensationPlan: CompensationPlanInsurance} + if err := service.applyPublishEntitlements(t.Context(), owner.ID, &ordinary); err != nil { + t.Fatalf("应用普通发布权益失败: %v", err) + } + if ordinary.InsuranceFeeBps != 800 { + t.Fatalf("普通保险费率 = %d, want 800", ordinary.InsuranceFeeBps) + } +} + +func TestApplyPublishEntitlementsRejectsNonQualityPremiumOwner(t *testing.T) { + db := database.NewTestDB() + owner := model.User{ + Phone: "13900009312", + RealnameStatus: "verified", + Status: "active", + } + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("创建普通号主失败: %v", err) + } + service := NewService(NewRepository(db, nil), stubConfigReader{ + qualityowner.ConfigKey: qualityowner.DefaultConfigValue(), + }) + req := CreateRequest{IsPremium: true, CompensationPlan: CompensationPlanInsurance} + if err := service.applyPublishEntitlements(t.Context(), owner.ID, &req); !errors.Is(err, ErrPremiumOwnerRequired) { + t.Fatalf("error = %v, want ErrPremiumOwnerRequired", err) + } +} diff --git a/backend/internal/modules/listing/query.go b/backend/internal/modules/listing/query.go index 4b351eb..5d986f5 100644 --- a/backend/internal/modules/listing/query.go +++ b/backend/internal/modules/listing/query.go @@ -225,6 +225,7 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB { return r.db.WithContext(ctx).Table("rental_listings AS l"). Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level, a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname, + COALESCE(u.quality_owner_status = 'active', 0) AS owner_is_quality_owner, CASE WHEN lu.id IS NULL THEN 0 ELSE 1 END AS is_external_upload, COALESCE(src_site.site_code, '') AS distribution_site_code, COALESCE(src_site.site_name, '') AS distribution_site_name, diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index 92a9564..05def5b 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -1,6 +1,7 @@ package listing import ( + "context" "errors" "sync" "time" @@ -57,6 +58,20 @@ func (r *Repository) syncPublishedShelf(tx *gorm.DB, listing *model.RentalListin return r.shelfSyncer.EnsureListingShelvedTx(tx, listing.ID, listing.AccountID) } +func (r *Repository) IsQualityOwner(ctx context.Context, userID uint64) (bool, error) { + if r == nil || r.db == nil { + return false, ErrDependencyUnavailable + } + var status string + err := r.db.WithContext(ctx).Model(&model.User{}). + Where("id = ?", userID). + Pluck("quality_owner_status", &status).Error + if err != nil { + return false, err + } + return status == "active", nil +} + type publicZoneCountCache struct { Counts map[string]int64 ExpiresAt time.Time diff --git a/backend/internal/modules/listing/review.go b/backend/internal/modules/listing/review.go index 167ab71..87d2938 100644 --- a/backend/internal/modules/listing/review.go +++ b/backend/internal/modules/listing/review.go @@ -61,6 +61,9 @@ func (r *Repository) TransferOwner(ctx context.Context, adminID uint64, listingI } return err } + if listing.IsPremium && target.QualityOwnerStatus != "active" { + return ErrPremiumOwnerRequired + } beforeOwnerID := listing.OwnerID listing.OwnerID = target.ID diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go index 634ac89..1ecc367 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -33,6 +33,7 @@ var ( ErrExternalAdjustment = errors.New("invalid external listing adjustment") ErrSaleRatioOutOfRange = errors.New("sale ratio out of range") ErrDistributionSiteInvalid = errors.New("invalid distribution site") + ErrPremiumOwnerRequired = errors.New("premium listing requires quality owner") ) type Service struct { @@ -52,6 +53,8 @@ const ( defaultPublishCooldownMinutes = 5 maxExternalUploadItems = 10 defaultUploadScreenshot = "/api/listings/default-upload-screenshot?v=webp1" + CompensationPlanNormal = "normal" + CompensationPlanInsurance = "insurance" ) type PublishCooldownError struct { diff --git a/backend/internal/modules/listing/service_mutation.go b/backend/internal/modules/listing/service_mutation.go index b75586f..178fb7e 100644 --- a/backend/internal/modules/listing/service_mutation.go +++ b/backend/internal/modules/listing/service_mutation.go @@ -3,6 +3,8 @@ package listing import ( "context" "strings" + + "hfb_sys/backend/internal/modules/qualityowner" ) func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest, siteCode ...string) (*ListingDTO, error) { @@ -30,6 +32,9 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest, if err := validateRequiredOnlineTime(req); err != nil { return nil, err } + if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil { + return nil, err + } cooldown, err := s.publishCooldown(ctx) if err != nil { return nil, err @@ -63,6 +68,9 @@ func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req Upd if err := validateRequiredOnlineTime(req); err != nil { return nil, err } + if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil { + return nil, err + } reviewRequired, err := s.reviewRequired(ctx) if err != nil { return nil, err @@ -70,6 +78,45 @@ func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req Upd return s.repo.Update(ctx, ownerID, id, req, reviewRequired) } +func (s *Service) applyPublishEntitlements(ctx context.Context, ownerID uint64, req *CreateRequest) error { + req.CompensationPlan = strings.TrimSpace(req.CompensationPlan) + if req.CompensationPlan == "" { + req.CompensationPlan = CompensationPlanNormal + } + if req.CompensationPlan != CompensationPlanNormal && req.CompensationPlan != CompensationPlanInsurance { + return ErrInvalidInput + } + + isQualityOwner, err := s.repo.IsQualityOwner(ctx, ownerID) + if err != nil { + return err + } + if req.IsPremium && !isQualityOwner { + return ErrPremiumOwnerRequired + } + + req.InsuranceFeeBps = 0 + if req.CompensationPlan != CompensationPlanInsurance { + return nil + } + rules := qualityowner.DefaultRules() + if s.config != nil { + value, err := s.config.FindValue(ctx, qualityowner.ConfigKey) + if err != nil { + return err + } + rules, err = qualityowner.ParseConfigValue(value) + if err != nil { + return err + } + } + req.InsuranceFeeBps = rules.InsuranceFeeBps + if req.IsPremium && isQualityOwner { + req.InsuranceFeeBps = rules.PremiumInsuranceFeeBps + } + return nil +} + func (s *Service) SubmitReview(ctx context.Context, ownerID uint64, id uint64) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index 3d14646..499be95 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -46,6 +46,7 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent order.ActualPureCoinDiscountCent = settlement.PureCoinDiscountCent order.ActualExtraItemAmountCent = settlement.ExtraItemAmountCent + order.InsuranceFeeCent = settlement.InsuranceFeeCent if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil { return nil, err } @@ -140,6 +141,8 @@ func applyCheckoutSettlement(checkout *model.OrderCheckout, settlement checkoutS checkout.RentAmountCent = settlement.ActualRentAmountCent checkout.OwnerRentAmountCent = settlement.OwnerRentIncomeCent checkout.PlatformFeeCent = settlement.PlatformFeeCent + checkout.InsuranceFeeBps = settlement.InsuranceFeeBps + checkout.InsuranceFeeCent = settlement.InsuranceFeeCent checkout.PureCoinAmountCent = settlement.PureCoinAmountCent checkout.PureCoinDiscountCent = settlement.PureCoinDiscountCent checkout.PureCoinPayableCent = settlement.PureCoinPayableCent diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index e4f9a81..a84c399 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -58,6 +58,10 @@ type OrderDTO struct { AccountSnapshot datatypes.JSON `json:"account_snapshot"` AccountSource string `json:"account_source"` SourceChannel string `json:"source_channel,omitempty"` + IsPremium bool `json:"is_premium"` + CompensationPlan string `json:"compensation_plan"` + InsuranceFeeBps int `json:"insurance_fee_bps"` + InsuranceFeeCent int64 `json:"insurance_fee_cent"` Status string `json:"status"` HandoffStatus string `json:"handoff_status"` HandoffMode string `json:"handoff_mode"` @@ -330,6 +334,8 @@ type CheckoutDTO struct { RentAmountCent *int64 `json:"rent_amount_cent,omitempty"` OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"` PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"` + InsuranceFeeBps int `json:"insurance_fee_bps"` + InsuranceFeeCent int64 `json:"insurance_fee_cent"` DepositAmountCent int64 `json:"deposit_amount_cent"` PureCoinAmountCent int64 `json:"pure_coin_amount_cent"` PureCoinDiscountCent int64 `json:"pure_coin_discount_cent"` diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 7520254..9606e8a 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -151,6 +151,9 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ AccountSnapshot: snapshot, AccountSource: accountSource, SourceChannel: sourceChannel, + IsPremium: listing.IsPremium, + CompensationPlan: normalizedCompensationPlan(listing.CompensationPlan), + InsuranceFeeBps: listing.InsuranceFeeBps, Status: orderStatusPendingPayment, HandoffStatus: handoffStatusNone, HandoffMode: listingHandoffMode(listing), @@ -194,6 +197,13 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ return r.FindForUser(ctx, renterID, createdID, req.DistributionSiteCode) } +func normalizedCompensationPlan(plan string) string { + if plan == "insurance" { + return "insurance" + } + return "normal" +} + func loadShelfRatioTx(tx *gorm.DB, siteCode string, listingID uint64) (float64, uint64, error) { siteCode = strings.TrimSpace(siteCode) if siteCode == "" || listingID == 0 { diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index b975a1c..2c9f703 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -115,6 +115,10 @@ func (row orderRow) toAdminDTO() OrderDTO { AccountSnapshot: row.AccountSnapshot, AccountSource: effectiveAccountSource(row.RentalOrder), SourceChannel: row.SourceChannel, + IsPremium: row.IsPremium, + CompensationPlan: normalizedCompensationPlan(row.CompensationPlan), + InsuranceFeeBps: row.InsuranceFeeBps, + InsuranceFeeCent: row.InsuranceFeeCent, Status: row.Status, HandoffStatus: row.HandoffStatus, HandoffMode: effectiveHandoffMode(row.RentalOrder), @@ -312,6 +316,8 @@ func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO { RentAmountCent: &rentAmountCent, OwnerRentAmountCent: &ownerRentAmountCent, PlatformFeeCent: &platformFeeCent, + InsuranceFeeBps: checkout.InsuranceFeeBps, + InsuranceFeeCent: checkout.InsuranceFeeCent, DepositAmountCent: checkout.DepositAmountCent, PureCoinAmountCent: checkout.PureCoinAmountCent, PureCoinDiscountCent: checkout.PureCoinDiscountCent, diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go index c26dc4f..d9ab64b 100644 --- a/backend/internal/modules/order/pricing.go +++ b/backend/internal/modules/order/pricing.go @@ -32,6 +32,8 @@ type checkoutSettlement struct { DepositRefundCent int64 RenterRefundCent int64 PlatformFeeCent int64 + InsuranceFeeBps int + InsuranceFeeCent int64 ActualRentAmountCent int64 PureCoinAmountCent int64 PureCoinDiscountCent int64 @@ -59,6 +61,8 @@ type ActualCheckoutSettlement struct { DepositRefundCent int64 RenterRefundCent int64 PlatformFeeCent int64 + InsuranceFeeBps int + InsuranceFeeCent int64 ActualRentAmountCent int64 PureCoinAmountCent int64 PureCoinDiscountCent int64 @@ -94,6 +98,8 @@ func toActualCheckoutSettlement(settlement checkoutSettlement) ActualCheckoutSet DepositRefundCent: settlement.DepositRefundCent, RenterRefundCent: settlement.RenterRefundCent, PlatformFeeCent: settlement.PlatformFeeCent, + InsuranceFeeBps: settlement.InsuranceFeeBps, + InsuranceFeeCent: settlement.InsuranceFeeCent, ActualRentAmountCent: settlement.ActualRentAmountCent, PureCoinAmountCent: settlement.PureCoinAmountCent, PureCoinDiscountCent: settlement.PureCoinDiscountCent, @@ -273,6 +279,8 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c RentAmountCent: settlement.ActualRentAmountCent, OwnerRentAmountCent: settlement.OwnerRentIncomeCent, PlatformFeeCent: settlement.PlatformFeeCent, + InsuranceFeeBps: settlement.InsuranceFeeBps, + InsuranceFeeCent: settlement.InsuranceFeeCent, DepositAmountCent: order.DepositAmountCent, PureCoinAmountCent: settlement.PureCoinAmountCent, PureCoinDiscountCent: settlement.PureCoinDiscountCent, @@ -398,6 +406,8 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i // 可完结时租客实际承担的租金向金额;有 shortfall 时仍展示完整应付便于协商 actualRentAmountCent := actualBuyerRentCent ownerRentIncomeCent := actualOwnerRentCent + insuranceFeeCent := calculateInsuranceFeeCent(ownerRentIncomeCent, order.InsuranceFeeBps) + ownerRentIncomeCent = maxCent(ownerRentIncomeCent-insuranceFeeCent, 0) platformFeeCent := maxCent(actualRentAmountCent-ownerRentIncomeCent, 0) depositCompensationCent := damageAppliedCent @@ -409,6 +419,8 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i DepositRefundCent: depositRefundCent, RenterRefundCent: rentRefundCent + depositRefundCent, PlatformFeeCent: platformFeeCent, + InsuranceFeeBps: order.InsuranceFeeBps, + InsuranceFeeCent: insuranceFeeCent, ActualRentAmountCent: actualRentAmountCent, PureCoinAmountCent: usedBuyerCoinPriceCent, PureCoinDiscountCent: pureCoinDiscountCent, @@ -420,6 +432,14 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i } } +func calculateInsuranceFeeCent(ownerRentIncomeCent int64, feeBps int) int64 { + if ownerRentIncomeCent <= 0 || feeBps <= 0 { + return 0 + } + feeCent := (ownerRentIncomeCent*int64(feeBps) + 5_000) / 10_000 + return minCent(feeCent, ownerRentIncomeCent) +} + func orderPureCoinOriginalAmountCent(order model.RentalOrder) int64 { originalRentCent := effectiveRentOriginalAmountCent(order) if order.PureCoinOriginalAmountCent > 0 { diff --git a/backend/internal/modules/order/pricing_insurance_test.go b/backend/internal/modules/order/pricing_insurance_test.go new file mode 100644 index 0000000..80aad44 --- /dev/null +++ b/backend/internal/modules/order/pricing_insurance_test.go @@ -0,0 +1,61 @@ +package order + +import ( + "testing" + + "hfb_sys/backend/internal/model" + + "gorm.io/datatypes" +) + +func TestCalculateInsuranceFeeCent(t *testing.T) { + tests := []struct { + name string + ownerCent int64 + bps int + want int64 + }{ + {name: "普通赔付不收费", ownerCent: 10000, bps: 0, want: 0}, + {name: "普通保险百分之八", ownerCent: 10000, bps: 800, want: 800}, + {name: "极品保险百分之四", ownerCent: 10000, bps: 400, want: 400}, + {name: "四舍五入到分", ownerCent: 12345, bps: 800, want: 988}, + {name: "服务费不超过到手租金", ownerCent: 100, bps: 10000, want: 100}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := calculateInsuranceFeeCent(tt.ownerCent, tt.bps); got != tt.want { + t.Fatalf("calculateInsuranceFeeCent() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestCalculateCheckoutSettlementDeductsPremiumInsuranceFee(t *testing.T) { + order := model.RentalOrder{ + RentAmountCent: 38300, + OwnerRentAmountCent: 35300, + DepositAmountCent: 15000, + InsuranceFeeBps: 400, + AccountSnapshot: datatypes.JSON([]byte(`{ + "haf_coin_amount": 100000000, + "asset_summary": { + "price_breakdown": { + "buyer_coin_base_price": 263, + "seller_coin_base_price": 233, + "consumable_price": 120 + } + } + }`)), + } + + settlement := calculateCheckoutSettlement(order, 12000, 100, 0) + if settlement.InsuranceFeeBps != 400 || settlement.InsuranceFeeCent != 1412 { + t.Fatalf("保险费用快照不正确: bps=%d fee=%d", settlement.InsuranceFeeBps, settlement.InsuranceFeeCent) + } + if settlement.OwnerRentIncomeCent != 33888 { + t.Fatalf("号主净租金 = %d, want 33888", settlement.OwnerRentIncomeCent) + } + if settlement.PlatformFeeCent != 4412 { + t.Fatalf("平台收入 = %d, want 4412", settlement.PlatformFeeCent) + } +} diff --git a/backend/internal/modules/payment/channel_status.go b/backend/internal/modules/payment/channel_status.go index 999d7b9..12e0ceb 100644 --- a/backend/internal/modules/payment/channel_status.go +++ b/backend/internal/modules/payment/channel_status.go @@ -48,30 +48,45 @@ func (r *Repository) confirmPaid(ctx context.Context, payment *model.PaymentOrde var newConvID uint64 var notifyFn func(uint64) err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if payment.OrderID != 0 { - switch payment.BizType { - case "mohong_pay": - if r.mohongRepo == nil { - return ErrDependencyUnavailable - } - convID, err := r.mohongRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID) - if err != nil { - return err - } - newConvID = convID - notifyFn = r.mohongRepo.NotifyNewConversation - default: - if r.orderRepo == nil { - return ErrDependencyUnavailable - } - convID, err := r.orderRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID) - if err != nil { - return err - } - newConvID = convID - notifyFn = r.orderRepo.NotifyNewConversation - if err := distribution.SnapshotPaidOrderTx(tx, payment.OrderID, payment.AmountCent); err != nil { - return err + switch payment.BizType { + case qualityOwnerPayBizType: + if r.qualityOwnerRepo == nil { + return ErrDependencyUnavailable + } + if err := r.qualityOwnerRepo.ConfirmQualityOwnerDepositPaidTx( + tx, + payment.UserID, + payment.AmountCent, + payment.PaymentNo, + ); err != nil { + return err + } + default: + if payment.OrderID != 0 { + switch payment.BizType { + case "mohong_pay": + if r.mohongRepo == nil { + return ErrDependencyUnavailable + } + convID, err := r.mohongRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID) + if err != nil { + return err + } + newConvID = convID + notifyFn = r.mohongRepo.NotifyNewConversation + default: + if r.orderRepo == nil { + return ErrDependencyUnavailable + } + convID, err := r.orderRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID) + if err != nil { + return err + } + newConvID = convID + notifyFn = r.orderRepo.NotifyNewConversation + if err := distribution.SnapshotPaidOrderTx(tx, payment.OrderID, payment.AmountCent); err != nil { + return err + } } } } diff --git a/backend/internal/modules/payment/dto.go b/backend/internal/modules/payment/dto.go index a061a6f..1311fdf 100644 --- a/backend/internal/modules/payment/dto.go +++ b/backend/internal/modules/payment/dto.go @@ -21,6 +21,7 @@ type PaymentDTO struct { PaymentNo string `json:"payment_no"` OrderID uint64 `json:"order_id"` OrderNo string `json:"order_no"` + BizID uint64 `json:"biz_id"` PaymentConfigID uint64 `json:"payment_config_id"` Provider string `json:"provider"` ThirdOrderID string `json:"third_order_id"` @@ -28,6 +29,7 @@ type PaymentDTO struct { PayWay string `json:"pay_way"` JSPayFlag string `json:"jspay_flag"` AmountCent int64 `json:"amount_cent"` + BizType string `json:"biz_type"` Status string `json:"status"` TDCode string `json:"td_code,omitempty"` JSPayURL string `json:"jspay_url,omitempty"` @@ -80,6 +82,7 @@ type AdminPaymentDTO struct { PaymentNo string `json:"payment_no"` OrderID uint64 `json:"order_id"` OrderNo string `json:"order_no"` + BizID uint64 `json:"biz_id"` UserID uint64 `json:"user_id"` PaymentConfigID uint64 `json:"payment_config_id"` UserPhone string `json:"user_phone"` diff --git a/backend/internal/modules/payment/handler.go b/backend/internal/modules/payment/handler.go index 735dc27..34afc80 100644 --- a/backend/internal/modules/payment/handler.go +++ b/backend/internal/modules/payment/handler.go @@ -111,6 +111,22 @@ func (h *Handler) StartMohong(c *gin.Context) { response.OK(c, item) } +func (h *Handler) StartQualityOwner(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + var req StartPaymentRequest + _ = c.ShouldBindJSON(&req) + item, err := h.service.StartQualityOwner(c.Request.Context(), userID, req, c.ClientIP()) + if err != nil { + writePaymentError(c, err) + return + } + response.OK(c, item) +} + func (h *Handler) Query(c *gin.Context) { userID, ok := currentUserID(c) if !ok { @@ -147,6 +163,20 @@ func (h *Handler) QueryMohong(c *gin.Context) { response.OK(c, item) } +func (h *Handler) QueryQualityOwner(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + item, err := h.service.QueryQualityOwner(c.Request.Context(), userID) + if err != nil { + writePaymentError(c, err) + return + } + response.OK(c, item) +} + func (h *Handler) AdminRefundMohong(c *gin.Context) { orderID, ok := parseID(c) if !ok { diff --git a/backend/internal/modules/payment/payment_start.go b/backend/internal/modules/payment/payment_start.go index 1137c55..d3e2220 100644 --- a/backend/internal/modules/payment/payment_start.go +++ b/backend/internal/modules/payment/payment_start.go @@ -20,6 +20,11 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r if err != nil { return nil, err } + return r.startPreparedPayment(ctx, payment, "租号订单 "+orderRow.OrderNo, orderRow.OrderNo, clientIP) +} + +// startPreparedPayment 对已经创建或复用的支付单执行渠道下单、收银台复用或模拟支付。 +func (r *Repository) startPreparedPayment(ctx context.Context, payment *model.PaymentOrder, body string, attach string, clientIP string) (*PaymentDTO, error) { runtimeConfig, err := r.runtimeConfigForPayment(ctx, payment) if err != nil { return nil, ErrPaymentUnavailable @@ -64,8 +69,8 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r NotifyURL: runtimeConfig.NotifyURL, JumpURL: runtimeConfig.JumpURL, ClientIP: clientIP, - Body: "租号订单 " + orderRow.OrderNo, - Attach: orderRow.OrderNo, + Body: body, + Attach: attach, }) if err != nil { _ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error()) diff --git a/backend/internal/modules/payment/presenter.go b/backend/internal/modules/payment/presenter.go index 32642a9..ea5fda4 100644 --- a/backend/internal/modules/payment/presenter.go +++ b/backend/internal/modules/payment/presenter.go @@ -19,6 +19,7 @@ func toDTO(payment model.PaymentOrder) PaymentDTO { PaymentNo: payment.PaymentNo, OrderID: payment.OrderID, OrderNo: payment.OrderNo, + BizID: payment.BizID, PaymentConfigID: payment.PaymentConfigID, Provider: payment.Provider, ThirdOrderID: payment.ThirdOrderID, @@ -26,6 +27,7 @@ func toDTO(payment model.PaymentOrder) PaymentDTO { PayWay: payment.PayWay, JSPayFlag: payment.JSPayFlag, AmountCent: payment.AmountCent, + BizType: payment.BizType, Status: payment.Status, TDCode: payment.TDCode, JSPayURL: payment.JSPayURL, diff --git a/backend/internal/modules/payment/quality_owner_payment.go b/backend/internal/modules/payment/quality_owner_payment.go new file mode 100644 index 0000000..4f36abc --- /dev/null +++ b/backend/internal/modules/payment/quality_owner_payment.go @@ -0,0 +1,158 @@ +package payment + +import ( + "context" + "fmt" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const qualityOwnerPayBizType = "quality_owner_pay" + +// StartQualityOwner 创建优质号主保证金在线支付单,支付成功后自动开通资格并冻结保证金。 +func (r *Repository) StartQualityOwner(ctx context.Context, userID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) { + if r.qualityOwnerRepo == nil { + return nil, ErrDependencyUnavailable + } + req.PayWay = normalizePayWay(req.PayWay) + payment, err := r.prepareQualityOwnerPayment(ctx, userID, req) + if err != nil { + return nil, err + } + return r.startPreparedPayment(ctx, payment, "优质号主保证金", payment.OrderNo, clientIP) +} + +func (r *Repository) prepareQualityOwnerPayment(ctx context.Context, userID uint64, req StartPaymentRequest) (*model.PaymentOrder, error) { + amountCent, active, err := r.qualityOwnerRepo.PaymentAmount(ctx, userID) + if err != nil { + return nil, err + } + if active || amountCent <= 0 { + return nil, ErrPaymentCannotStart + } + runtimeConfig, err := r.defaultRuntimeConfig(ctx, req.PayWay) + if err != nil { + return nil, err + } + + var paymentID uint64 + err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Select("id"). + First(&user, userID).Error; err != nil { + return err + } + var existing model.PaymentOrder + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("user_id = ? AND biz_type = ? AND biz_id = ?", userID, qualityOwnerPayBizType, userID). + Order("id DESC"). + First(&existing).Error + if err == nil { + if canReuseOrderPayment(existing, *runtimeConfig) { + existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, runtimeConfig.PayWay, "ZFBZF") + existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, runtimeConfig.JSPayFlag, "2") + existing.AmountCent = amountCent + existing.PaymentConfigID = firstNonZero(existing.PaymentConfigID, runtimeConfig.ID) + existing.Provider = firstNonEmpty(existing.Provider, runtimeConfig.Provider) + existing.MerchantID = firstNonEmpty(existing.MerchantID, runtimeConfig.MerchantID) + if existing.Provider == "mock" && existing.ProviderOrderID == "" { + existing.ProviderOrderID = "MOCK" + existing.ThirdOrderID + } + if err := tx.Save(&existing).Error; err != nil { + return err + } + paymentID = existing.ID + return nil + } + } else if err != gorm.ErrRecordNotFound { + return err + } + payment, err := newQualityOwnerPayment(userID, amountCent, req, *runtimeConfig) + if err != nil { + return err + } + if err := tx.Create(&payment).Error; err != nil { + return err + } + paymentID = payment.ID + return nil + }) + if err != nil { + return nil, err + } + return r.findPaymentByID(ctx, paymentID) +} + +func newQualityOwnerPayment(userID uint64, amountCent int64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (model.PaymentOrder, error) { + paymentNo, err := newPaymentNo() + if err != nil { + return model.PaymentOrder{}, err + } + orderNo := fmt.Sprintf("QO%d", userID) + payment := model.PaymentOrder{ + PaymentNo: paymentNo, + OrderID: 0, + OrderNo: orderNo, + BizID: userID, + UserID: userID, + PaymentConfigID: runtimeConfig.ID, + Provider: runtimeConfig.Provider, + MerchantID: runtimeConfig.MerchantID, + ThirdOrderID: paymentNo, + PayWay: firstNonEmpty(req.PayWay, runtimeConfig.PayWay, "ZFBZF"), + JSPayFlag: firstNonEmpty(req.JSPayFlag, runtimeConfig.JSPayFlag, "2"), + AmountCent: amountCent, + BizType: qualityOwnerPayBizType, + Status: "created", + } + if runtimeConfig.isMockMode() { + payment.ProviderOrderID = "MOCK" + paymentNo + payment.TDCode = "mock://payment/pay/" + paymentNo + } + return payment, nil +} + +func (r *Repository) QueryQualityOwner(ctx context.Context, userID uint64) (*PaymentDTO, error) { + var payment model.PaymentOrder + if err := r.db.WithContext(ctx). + Where("user_id = ? AND biz_type = ? AND biz_id = ?", userID, qualityOwnerPayBizType, userID). + Order("id DESC"). + First(&payment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrPaymentNotFound + } + return nil, err + } + if isOrderPaymentTerminalStatus(payment.Status) { + dto := toDTO(payment) + return &dto, nil + } + runtimeConfig, err := r.runtimeConfigForPayment(ctx, &payment) + if err != nil { + return nil, ErrPaymentUnavailable + } + if runtimeConfig.isMockMode() { + dto := toDTO(payment) + return &dto, nil + } + if runtimeConfig.Channel == nil { + return nil, ErrPaymentUnavailable + } + resp, err := runtimeConfig.Channel.QueryPayment(ctx, payment.ThirdOrderID, payment.ProviderOrderID) + if err != nil { + return nil, err + } + if err := r.applyChannelStatus(ctx, &payment, resp.Status, resp.PayTime, resp.Raw, channelSourceQuery); err != nil { + return nil, err + } + latest, err := r.findPaymentByID(ctx, payment.ID) + if err != nil { + return nil, err + } + dto := toDTO(*latest) + return &dto, nil +} diff --git a/backend/internal/modules/payment/quality_owner_payment_test.go b/backend/internal/modules/payment/quality_owner_payment_test.go new file mode 100644 index 0000000..98fae46 --- /dev/null +++ b/backend/internal/modules/payment/quality_owner_payment_test.go @@ -0,0 +1,97 @@ +package payment + +import ( + "testing" + "time" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/qualityowner" +) + +func TestConfirmQualityOwnerPaymentFreezesDepositAndIsIdempotent(t *testing.T) { + db := database.NewTestDB() + user := model.User{ + Phone: "13900009401", + RealnameStatus: "verified", + Status: "active", + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("创建用户失败: %v", err) + } + if err := db.Create(&model.WalletAccount{ + UserID: user.ID, + AvailableBalanceCent: 0, + Status: "active", + }).Error; err != nil { + t.Fatalf("创建钱包失败: %v", err) + } + payment := model.PaymentOrder{ + PaymentNo: "PAYQUALITYOWNER01", + OrderID: 0, + OrderNo: "QOQUALITY", + BizID: user.ID, + UserID: user.ID, + Provider: "mock", + ThirdOrderID: "PAYQUALITYOWNER01", + AmountCent: 20_000, + BizType: qualityOwnerPayBizType, + Status: "paying", + } + if err := db.Create(&payment).Error; err != nil { + t.Fatalf("创建支付单失败: %v", err) + } + repo := NewRepository(db, nil, nil, WithQualityOwnerRepo(qualityowner.NewRepository(db))) + raw := map[string]string{"status": "2", "third_order_id": payment.ThirdOrderID} + if err := repo.confirmPaid(t.Context(), &payment, "2", time.Now(), raw, channelSourceMock); err != nil { + t.Fatalf("确认保证金支付失败: %v", err) + } + if err := repo.confirmPaid(t.Context(), &payment, "2", time.Now(), raw, channelSourceMock); err != nil { + t.Fatalf("重复确认保证金支付失败: %v", err) + } + + var gotUser model.User + if err := db.First(&gotUser, user.ID).Error; err != nil { + t.Fatalf("读取用户失败: %v", err) + } + if gotUser.QualityOwnerStatus != qualityowner.StatusActive || gotUser.QualityOwnerDepositCent != 20_000 { + t.Fatalf("用户未开通优质号主: %+v", gotUser) + } + var account model.WalletAccount + if err := db.Where("user_id = ? AND distribution_site_id = 0", user.ID).First(&account).Error; err != nil { + t.Fatalf("读取钱包失败: %v", err) + } + if account.AvailableBalanceCent != 0 || account.FrozenBalanceCent != 20_000 { + t.Fatalf("保证金钱包余额不正确: %+v", account) + } + var ledgerCount int64 + if err := db.Model(&model.WalletLedger{}). + Where("user_id = ? AND biz_type = ?", user.ID, "quality_owner_deposit"). + Count(&ledgerCount).Error; err != nil { + t.Fatalf("统计保证金流水失败: %v", err) + } + if ledgerCount != 1 { + t.Fatalf("保证金流水条数 = %d, want 1", ledgerCount) + } +} + +func TestNewQualityOwnerPaymentUsesBizIDAndMockCashier(t *testing.T) { + payment, err := newQualityOwnerPayment(88, 20_000, StartPaymentRequest{PayWay: "WXZF"}, runtimePaymentConfig{ + Provider: "mock", + MerchantID: "mock-merchant", + PayWay: "WXZF", + JSPayFlag: "2", + }) + if err != nil { + t.Fatalf("newQualityOwnerPayment() error = %v", err) + } + if payment.BizType != qualityOwnerPayBizType || payment.BizID != 88 || payment.UserID != 88 { + t.Fatalf("保证金支付单业务字段不正确: %+v", payment) + } + if payment.OrderID != 0 || payment.AmountCent != 20_000 { + t.Fatalf("保证金支付单订单/金额不正确: %+v", payment) + } + if payment.TDCode == "" || payment.ProviderOrderID == "" { + t.Fatalf("mock 支付单未生成收银台: %+v", payment) + } +} diff --git a/backend/internal/modules/payment/query.go b/backend/internal/modules/payment/query.go index c377405..45cc00f 100644 --- a/backend/internal/modules/payment/query.go +++ b/backend/internal/modules/payment/query.go @@ -107,6 +107,7 @@ func (row adminPaymentRow) toDTO() AdminPaymentDTO { PaymentNo: row.PaymentNo, OrderID: row.OrderID, OrderNo: row.OrderNo, + BizID: row.BizID, UserID: row.UserID, PaymentConfigID: row.PaymentConfigID, UserPhone: row.UserPhone, diff --git a/backend/internal/modules/payment/repository.go b/backend/internal/modules/payment/repository.go index 7180daf..7328e78 100644 --- a/backend/internal/modules/payment/repository.go +++ b/backend/internal/modules/payment/repository.go @@ -19,12 +19,19 @@ type MohongOrderConfirmer interface { NotifyNewConversation(conversationID uint64) } +// QualityOwnerPaymentAdapter 由优质号主模块实现,避免 payment 与 qualityowner 直接耦合。 +type QualityOwnerPaymentAdapter interface { + PaymentAmount(ctx context.Context, userID uint64) (amountCent int64, active bool, err error) + ConfirmQualityOwnerDepositPaidTx(tx *gorm.DB, userID uint64, amountCent int64, paymentNo string) error +} + type Repository struct { - db *gorm.DB - configRepo *paymentconfig.Repository - orderRepo *order.Repository - mohongRepo MohongOrderConfirmer - logger *zap.Logger + db *gorm.DB + configRepo *paymentconfig.Repository + orderRepo *order.Repository + mohongRepo MohongOrderConfirmer + qualityOwnerRepo QualityOwnerPaymentAdapter + logger *zap.Logger } type runtimePaymentConfig struct { @@ -96,6 +103,13 @@ func WithMohongRepo(repo MohongOrderConfirmer) RepositoryOption { } } +// WithQualityOwnerRepo 注入优质号主保证金支付适配器。 +func WithQualityOwnerRepo(repo QualityOwnerPaymentAdapter) RepositoryOption { + return func(r *Repository) { + r.qualityOwnerRepo = repo + } +} + func (r *Repository) log() *zap.Logger { if r == nil || r.logger == nil { return zap.NewNop() diff --git a/backend/internal/modules/payment/service.go b/backend/internal/modules/payment/service.go index ee4a50a..5a7a704 100644 --- a/backend/internal/modules/payment/service.go +++ b/backend/internal/modules/payment/service.go @@ -42,6 +42,16 @@ func (s *Service) StartMohong(ctx context.Context, userID uint64, orderID uint64 return s.repo.StartMohong(ctx, userID, orderID, req, clientIP) } +func (s *Service) StartQualityOwner(ctx context.Context, userID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if userID == 0 { + return nil, ErrPaymentCannotStart + } + return s.repo.StartQualityOwner(ctx, userID, req, clientIP) +} + func (s *Service) Query(ctx context.Context, userID uint64, orderID uint64) (*PaymentDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable @@ -62,6 +72,16 @@ func (s *Service) QueryMohong(ctx context.Context, userID uint64, orderID uint64 return s.repo.QueryMohong(ctx, userID, orderID) } +func (s *Service) QueryQualityOwner(ctx context.Context, userID uint64) (*PaymentDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if userID == 0 { + return nil, ErrPaymentNotFound + } + return s.repo.QueryQualityOwner(ctx, userID) +} + func (s *Service) HandleLeshuaNotify(ctx context.Context, params map[string]string, rawPayload string, contentType string) (*NotifyResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/modules/qualityowner/dto.go b/backend/internal/modules/qualityowner/dto.go new file mode 100644 index 0000000..b20a859 --- /dev/null +++ b/backend/internal/modules/qualityowner/dto.go @@ -0,0 +1,85 @@ +package qualityowner + +import ( + "encoding/json" + "errors" + "time" +) + +const ( + ConfigKey = "quality_owner.rules" + StatusNone = "none" + StatusActive = "active" + DepositSourcePaid = "paid" + DepositSourceManual = "manual" + defaultDepositCent = int64(20_000) + defaultInsuranceBps = 800 + defaultPremiumRateBps = 400 +) + +var ErrInvalidRules = errors.New("invalid quality owner rules") + +type Rules struct { + DepositAmountCent int64 `json:"deposit_amount_cent"` + InsuranceFeeBps int `json:"insurance_fee_bps"` + PremiumInsuranceFeeBps int `json:"premium_insurance_fee_bps"` +} + +func DefaultRules() Rules { + return Rules{ + DepositAmountCent: defaultDepositCent, + InsuranceFeeBps: defaultInsuranceBps, + PremiumInsuranceFeeBps: defaultPremiumRateBps, + } +} + +func DefaultConfigValue() string { + raw, _ := json.Marshal(DefaultRules()) + return string(raw) +} + +func ParseConfigValue(value string) (Rules, error) { + rules := DefaultRules() + if value == "" { + return rules, nil + } + if err := json.Unmarshal([]byte(value), &rules); err != nil { + return Rules{}, ErrInvalidRules + } + if rules.DepositAmountCent <= 0 || rules.DepositAmountCent > 10_000_000 { + return Rules{}, ErrInvalidRules + } + if rules.InsuranceFeeBps < 0 || rules.InsuranceFeeBps > 10_000 { + return Rules{}, ErrInvalidRules + } + if rules.PremiumInsuranceFeeBps < 0 || rules.PremiumInsuranceFeeBps > rules.InsuranceFeeBps { + return Rules{}, ErrInvalidRules + } + return rules, nil +} + +func MarshalConfig(rules Rules) (string, error) { + raw, err := json.Marshal(rules) + if err != nil { + return "", err + } + return string(raw), nil +} + +type StatusDTO struct { + UserID uint64 `json:"user_id"` + Active bool `json:"active"` + Status string `json:"status"` + DepositAmountCent int64 `json:"deposit_amount_cent"` + DepositSource string `json:"deposit_source"` + ActivatedAt *time.Time `json:"activated_at"` + AvailableBalanceCent int64 `json:"available_balance_cent"` + RequiredDepositCent int64 `json:"required_deposit_cent"` + InsuranceFeeBps int `json:"insurance_fee_bps"` + PremiumInsuranceFeeBps int `json:"premium_insurance_fee_bps"` +} + +type AdminSetRequest struct { + Enabled bool `json:"enabled"` + Reason string `json:"reason" binding:"required"` +} diff --git a/backend/internal/modules/qualityowner/dto_test.go b/backend/internal/modules/qualityowner/dto_test.go new file mode 100644 index 0000000..110355f --- /dev/null +++ b/backend/internal/modules/qualityowner/dto_test.go @@ -0,0 +1,30 @@ +package qualityowner + +import "testing" + +func TestParseConfigValueDefaultsAndValidation(t *testing.T) { + rules, err := ParseConfigValue(`{"insurance_fee_bps":800,"premium_insurance_fee_bps":400}`) + if err != nil { + t.Fatalf("ParseConfigValue() error = %v", err) + } + if rules.DepositAmountCent != defaultDepositCent || rules.InsuranceFeeBps != 800 || rules.PremiumInsuranceFeeBps != 400 { + t.Fatalf("unexpected rules: %+v", rules) + } + + if _, err := ParseConfigValue(`{"deposit_amount_cent":20000,"insurance_fee_bps":400,"premium_insurance_fee_bps":800}`); err == nil { + t.Fatal("expected premium rate above normal rate to be rejected") + } + if _, err := ParseConfigValue(`{"deposit_amount_cent":0}`); err == nil { + t.Fatal("expected zero deposit to be rejected") + } +} + +func TestDefaultConfigValueCanBeParsed(t *testing.T) { + rules, err := ParseConfigValue(DefaultConfigValue()) + if err != nil { + t.Fatalf("ParseConfigValue(DefaultConfigValue()) error = %v", err) + } + if rules.DepositAmountCent != 20000 || rules.InsuranceFeeBps != 800 || rules.PremiumInsuranceFeeBps != 400 { + t.Fatalf("unexpected default rules: %+v", rules) + } +} diff --git a/backend/internal/modules/qualityowner/handler.go b/backend/internal/modules/qualityowner/handler.go new file mode 100644 index 0000000..629df9e --- /dev/null +++ b/backend/internal/modules/qualityowner/handler.go @@ -0,0 +1,119 @@ +package qualityowner + +import ( + "errors" + "net/http" + "strconv" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) Status(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + item, err := h.service.Status(c.Request.Context(), userID) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Activate(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + item, err := h.service.Activate(c.Request.Context(), userID) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) AdminSet(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + userID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || userID == 0 { + response.BadRequest(c, "用户 ID 不正确") + return + } + var req AdminSetRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请填写操作原因") + return + } + item, err := h.service.AdminSet(c.Request.Context(), adminID, userID, req, AuditMeta{ + IP: c.ClientIP(), + UserAgent: c.GetHeader("User-Agent"), + RequestID: middleware.GetRequestID(c), + }) + if err != nil { + writeError(c, err) + return + } + response.OK(c, item) +} + +func currentUserID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextUserID) + if !ok { + return 0, false + } + id, ok := value.(uint64) + return id, ok +} + +func currentAdminID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextAdminID) + if !ok { + return 0, false + } + id, ok := value.(uint64) + return id, ok +} + +func writeError(c *gin.Context, err error) { + response.RecordError(c, err) + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + case errors.Is(err, ErrInvalidUser): + response.BadRequest(c, "用户不符合开通条件,请先完成实名认证") + case errors.Is(err, ErrDepositInsufficient): + response.Error(c, http.StatusConflict, "insufficient_balance", "余额不足,请先确保可用余额足够缴纳保证金") + case errors.Is(err, ErrAlreadyActive): + response.Error(c, http.StatusConflict, "quality_owner_active", "当前已经是优质号主") + case errors.Is(err, ErrPaymentPending): + response.Error(c, http.StatusConflict, "quality_owner_payment_pending", "已有待完成的保证金支付,请先完成支付或稍后重试") + case errors.Is(err, ErrInactive): + response.Error(c, http.StatusConflict, "quality_owner_inactive", "当前不是优质号主") + case errors.Is(err, ErrInvalidReason): + response.BadRequest(c, "请填写 2-200 字的操作原因") + case errors.Is(err, gorm.ErrRecordNotFound): + response.Error(c, http.StatusNotFound, "not_found", "用户不存在") + default: + response.Error(c, http.StatusInternalServerError, "internal_error", "优质号主服务暂时不可用") + } +} diff --git a/backend/internal/modules/qualityowner/repository.go b/backend/internal/modules/qualityowner/repository.go new file mode 100644 index 0000000..8ecd033 --- /dev/null +++ b/backend/internal/modules/qualityowner/repository.go @@ -0,0 +1,378 @@ +package qualityowner + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "hfb_sys/backend/internal/auditlog" + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/wallet" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrInvalidUser = errors.New("invalid user") + ErrAlreadyActive = errors.New("quality owner already active") + ErrInactive = errors.New("quality owner inactive") + ErrDepositInsufficient = errors.New("quality owner deposit insufficient") + ErrInvalidReason = errors.New("invalid reason") + ErrPaymentPending = errors.New("quality owner payment pending") +) + +type AuditMeta = auditlog.Meta + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) Rules(ctx context.Context) (Rules, error) { + if r == nil || r.db == nil { + return Rules{}, ErrDependencyUnavailable + } + var row model.SystemConfig + err := r.db.WithContext(ctx).Where("`key` = ?", ConfigKey).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return DefaultRules(), nil + } + if err != nil { + return Rules{}, err + } + return ParseConfigValue(row.Value) +} + +func (r *Repository) Status(ctx context.Context, userID uint64) (*StatusDTO, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + if userID == 0 { + return nil, ErrInvalidUser + } + rules, err := r.Rules(ctx) + if err != nil { + return nil, err + } + var user model.User + if err := r.db.WithContext(ctx).First(&user, userID).Error; err != nil { + return nil, err + } + var account model.WalletAccount + walletErr := r.db.WithContext(ctx). + Where("user_id = ? AND distribution_site_id = 0", userID). + First(&account).Error + if walletErr != nil && !errors.Is(walletErr, gorm.ErrRecordNotFound) { + return nil, walletErr + } + return toStatusDTO(user, account, rules), nil +} + +// PaymentAmount 返回当前用户在线支付开通优质号主所需的保证金金额。 +// active=true 表示用户已经是优质号主,无需再创建支付单。 +func (r *Repository) PaymentAmount(ctx context.Context, userID uint64) (int64, bool, error) { + if r == nil || r.db == nil { + return 0, false, ErrDependencyUnavailable + } + if userID == 0 { + return 0, false, ErrInvalidUser + } + var user model.User + if err := r.db.WithContext(ctx).First(&user, userID).Error; err != nil { + return 0, false, err + } + if user.QualityOwnerStatus == StatusActive { + return 0, true, nil + } + if user.Status != "active" || user.RealnameStatus != "verified" { + return 0, false, ErrInvalidUser + } + rules, err := r.Rules(ctx) + if err != nil { + return 0, false, err + } + return rules.DepositAmountCent, false, nil +} + +// ConfirmQualityOwnerDepositPaidTx 在支付回调事务内完成在线保证金入账并开通资格。 +// 外部支付已经由渠道完成,因此这里只增加冻结余额,不再扣减可用余额。 +func (r *Repository) ConfirmQualityOwnerDepositPaidTx(tx *gorm.DB, userID uint64, amountCent int64, paymentNo string) error { + if tx == nil || userID == 0 || amountCent <= 0 { + return ErrInvalidUser + } + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { + return err + } + // 同一支付回调可能重复到达;已经是优质号主时保持幂等。 + if user.QualityOwnerStatus == StatusActive { + return nil + } + if err := wallet.EnsureAccountTx(tx, userID, 0); err != nil { + return err + } + if err := wallet.AppendEntries(tx, wallet.Entry{ + UserID: userID, + Direction: "in", + AmountCent: amountCent, + BalanceType: "frozen", + BizType: "quality_owner_deposit", + BizNo: paymentNo, + Remark: "在线支付优质号主保证金", + }); err != nil { + return err + } + now := time.Now() + beforeStatus := user.QualityOwnerStatus + user.QualityOwnerStatus = StatusActive + user.QualityOwnerDepositCent = amountCent + user.QualityOwnerDepositSource = DepositSourcePaid + user.QualityOwnerActivatedAt = &now + if err := tx.Save(&user).Error; err != nil { + return err + } + return auditlog.Append(tx, auditlog.Entry{ + ActorType: "user", + ActorID: userID, + Action: "quality_owner.activate", + BizType: "user", + BizID: &userID, + Detail: map[string]any{ + "before_status": beforeStatus, + "after_status": user.QualityOwnerStatus, + "deposit_amount_cent": amountCent, + "deposit_source": DepositSourcePaid, + "payment_no": paymentNo, + "pay_method": "online", + }, + }) +} + +func (r *Repository) Activate(ctx context.Context, userID uint64) (*StatusDTO, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + if userID == 0 { + return nil, ErrInvalidUser + } + rules, err := r.Rules(ctx) + if err != nil { + return nil, err + } + err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { + return err + } + if user.RealnameStatus != "verified" { + return ErrInvalidUser + } + if user.Status != "active" { + return ErrInvalidUser + } + if user.QualityOwnerStatus == StatusActive { + return ErrAlreadyActive + } + var pendingPaymentCount int64 + if err := tx.Model(&model.PaymentOrder{}). + Where( + "user_id = ? AND biz_type = ? AND status IN ? AND updated_at >= ?", + userID, + "quality_owner_pay", + []string{"created", "paying"}, + time.Now().Add(-30*time.Minute), + ). + Count(&pendingPaymentCount).Error; err != nil { + return err + } + if pendingPaymentCount > 0 { + return ErrPaymentPending + } + if err := wallet.EnsureAccountTx(tx, userID, 0); err != nil { + return err + } + bizNo := fmt.Sprintf("QO%d", userID) + err := wallet.AppendEntries(tx, + wallet.Entry{ + UserID: userID, + Direction: "out", + AmountCent: rules.DepositAmountCent, + BalanceType: "available", + BizType: "quality_owner_deposit", + BizNo: bizNo, + Remark: "缴纳优质号主保证金", + }, + wallet.Entry{ + UserID: userID, + Direction: "in", + AmountCent: rules.DepositAmountCent, + BalanceType: "frozen", + BizType: "quality_owner_deposit", + BizNo: bizNo, + Remark: "优质号主保证金冻结", + }, + ) + if errors.Is(err, wallet.ErrInsufficientBalance) { + return ErrDepositInsufficient + } + if err != nil { + return err + } + now := time.Now() + beforeStatus := user.QualityOwnerStatus + user.QualityOwnerStatus = StatusActive + user.QualityOwnerDepositCent = rules.DepositAmountCent + user.QualityOwnerDepositSource = DepositSourcePaid + user.QualityOwnerActivatedAt = &now + if err := tx.Save(&user).Error; err != nil { + return err + } + return auditlog.Append(tx, auditlog.Entry{ + ActorType: "user", + ActorID: userID, + Action: "quality_owner.activate", + BizType: "user", + BizID: &userID, + Detail: map[string]any{ + "before_status": beforeStatus, + "after_status": user.QualityOwnerStatus, + "deposit_amount_cent": rules.DepositAmountCent, + "deposit_source": DepositSourcePaid, + }, + }) + }) + if err != nil { + return nil, err + } + return r.Status(ctx, userID) +} + +func (r *Repository) AdminSet(ctx context.Context, adminID uint64, userID uint64, req AdminSetRequest, meta AuditMeta) (*StatusDTO, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + reason := strings.TrimSpace(req.Reason) + if userID == 0 || adminID == 0 { + return nil, ErrInvalidUser + } + if len([]rune(reason)) < 2 || len([]rune(reason)) > 200 { + return nil, ErrInvalidReason + } + rules, err := r.Rules(ctx) + if err != nil { + return nil, err + } + err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { + return err + } + beforeStatus := user.QualityOwnerStatus + beforeDepositCent := user.QualityOwnerDepositCent + beforeSource := user.QualityOwnerDepositSource + if req.Enabled { + if user.Status != "active" { + return ErrInvalidUser + } + if beforeStatus != StatusActive { + now := time.Now() + user.QualityOwnerStatus = StatusActive + user.QualityOwnerDepositCent = rules.DepositAmountCent + user.QualityOwnerDepositSource = DepositSourceManual + user.QualityOwnerActivatedAt = &now + } + } else { + if beforeStatus != StatusActive { + return ErrInactive + } + if beforeSource == DepositSourcePaid && beforeDepositCent > 0 { + err := wallet.AppendEntries(tx, + wallet.Entry{ + UserID: userID, + Direction: "out", + AmountCent: beforeDepositCent, + BalanceType: "frozen", + BizType: "quality_owner_deposit_refund", + BizNo: fmt.Sprintf("QOR%d", userID), + Remark: "取消优质号主,解冻保证金", + }, + wallet.Entry{ + UserID: userID, + Direction: "in", + AmountCent: beforeDepositCent, + BalanceType: "available", + BizType: "quality_owner_deposit_refund", + BizNo: fmt.Sprintf("QOR%d", userID), + Remark: "优质号主保证金退回", + }, + ) + if err != nil { + return err + } + } + user.QualityOwnerStatus = StatusNone + user.QualityOwnerDepositCent = 0 + user.QualityOwnerDepositSource = "" + user.QualityOwnerActivatedAt = nil + if err := tx.Model(&model.RentalListing{}). + Where("owner_id = ? AND is_premium = ? AND status IN ?", userID, true, []string{"draft", "published"}). + Updates(map[string]any{ + "status": "offline", + "review_status": "none", + "review_reason": "优质号主资格已取消", + }).Error; err != nil { + return err + } + } + if err := tx.Save(&user).Error; err != nil { + return err + } + if err := auditlog.Append(tx, auditlog.Entry{ + ActorType: "admin", + ActorID: adminID, + Action: "admin_user.set_quality_owner", + BizType: "user", + BizID: &userID, + Meta: meta, + Detail: map[string]any{ + "user_id": userID, + "reason": reason, + "before_status": beforeStatus, + "after_status": user.QualityOwnerStatus, + "before_deposit_cent": beforeDepositCent, + "after_deposit_cent": user.QualityOwnerDepositCent, + "before_deposit_source": beforeSource, + "after_deposit_source": user.QualityOwnerDepositSource, + }, + }); err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + return r.Status(ctx, userID) +} + +func toStatusDTO(user model.User, account model.WalletAccount, rules Rules) *StatusDTO { + return &StatusDTO{ + UserID: user.ID, + Active: user.QualityOwnerStatus == StatusActive, + Status: user.QualityOwnerStatus, + DepositAmountCent: user.QualityOwnerDepositCent, + DepositSource: user.QualityOwnerDepositSource, + ActivatedAt: user.QualityOwnerActivatedAt, + AvailableBalanceCent: account.AvailableBalanceCent, + RequiredDepositCent: rules.DepositAmountCent, + InsuranceFeeBps: rules.InsuranceFeeBps, + PremiumInsuranceFeeBps: rules.PremiumInsuranceFeeBps, + } +} diff --git a/backend/internal/modules/qualityowner/repository_integration_test.go b/backend/internal/modules/qualityowner/repository_integration_test.go new file mode 100644 index 0000000..cd7d3ac --- /dev/null +++ b/backend/internal/modules/qualityowner/repository_integration_test.go @@ -0,0 +1,114 @@ +package qualityowner + +import ( + "testing" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" +) + +func TestActivateAndAdminDisableRefundDeposit(t *testing.T) { + db := database.NewTestDB() + user := model.User{ + Phone: "13900009301", + RealnameStatus: "verified", + Status: "active", + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("创建用户失败: %v", err) + } + account := model.WalletAccount{ + UserID: user.ID, + AvailableBalanceCent: 30_000, + Status: "active", + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("创建钱包失败: %v", err) + } + + repo := NewRepository(db) + status, err := repo.Activate(t.Context(), user.ID) + if err != nil { + t.Fatalf("开通优质号主失败: %v", err) + } + if !status.Active || status.DepositAmountCent != 20_000 || status.DepositSource != DepositSourcePaid { + t.Fatalf("开通后的状态不正确: %+v", status) + } + if status.AvailableBalanceCent != 10_000 { + t.Fatalf("可用余额 = %d, want 10000", status.AvailableBalanceCent) + } + if err := db.First(&account, account.ID).Error; err != nil { + t.Fatalf("读取钱包失败: %v", err) + } + if account.FrozenBalanceCent != 20_000 { + t.Fatalf("冻结余额 = %d, want 20000", account.FrozenBalanceCent) + } + + status, err = repo.AdminSet(t.Context(), 99, user.ID, AdminSetRequest{ + Enabled: false, + Reason: "用户申请取消优质号主", + }, AuditMeta{RequestID: "quality-owner-disable"}) + if err != nil { + t.Fatalf("取消优质号主失败: %v", err) + } + if status.Active || status.DepositAmountCent != 0 { + t.Fatalf("取消后的状态不正确: %+v", status) + } + if status.AvailableBalanceCent != 30_000 { + t.Fatalf("退款后可用余额 = %d, want 30000", status.AvailableBalanceCent) + } + if err := db.First(&account, account.ID).Error; err != nil { + t.Fatalf("读取退款后钱包失败: %v", err) + } + if account.FrozenBalanceCent != 0 { + t.Fatalf("退款后冻结余额 = %d, want 0", account.FrozenBalanceCent) + } + + var ledgerCount int64 + if err := db.Model(&model.WalletLedger{}).Where("user_id = ?", user.ID).Count(&ledgerCount).Error; err != nil { + t.Fatalf("统计钱包流水失败: %v", err) + } + if ledgerCount != 4 { + t.Fatalf("钱包流水条数 = %d, want 4", ledgerCount) + } + var auditCount int64 + if err := db.Model(&model.AuditLog{}). + Where("action = ? AND biz_id = ?", "admin_user.set_quality_owner", user.ID). + Count(&auditCount).Error; err != nil { + t.Fatalf("统计审计日志失败: %v", err) + } + if auditCount != 1 { + t.Fatalf("后台设置审计日志条数 = %d, want 1", auditCount) + } +} + +func TestAdminCanGrantQualityOwnerWithoutWalletDeposit(t *testing.T) { + db := database.NewTestDB() + user := model.User{ + Phone: "13900009302", + RealnameStatus: "verified", + Status: "active", + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("创建用户失败: %v", err) + } + + repo := NewRepository(db) + status, err := repo.AdminSet(t.Context(), 99, user.ID, AdminSetRequest{ + Enabled: true, + Reason: "客服核验后手动开通", + }, AuditMeta{RequestID: "quality-owner-enable"}) + if err != nil { + t.Fatalf("后台开通优质号主失败: %v", err) + } + if !status.Active || status.DepositSource != DepositSourceManual || status.DepositAmountCent != 20_000 { + t.Fatalf("后台开通状态不正确: %+v", status) + } + var ledgerCount int64 + if err := db.Model(&model.WalletLedger{}).Where("user_id = ?", user.ID).Count(&ledgerCount).Error; err != nil { + t.Fatalf("统计钱包流水失败: %v", err) + } + if ledgerCount != 0 { + t.Fatalf("后台手动开通不应产生钱包流水,实际 %d 条", ledgerCount) + } +} diff --git a/backend/internal/modules/qualityowner/service.go b/backend/internal/modules/qualityowner/service.go new file mode 100644 index 0000000..149e691 --- /dev/null +++ b/backend/internal/modules/qualityowner/service.go @@ -0,0 +1,32 @@ +package qualityowner + +import "context" + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Status(ctx context.Context, userID uint64) (*StatusDTO, error) { + if s == nil || s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.Status(ctx, userID) +} + +func (s *Service) Activate(ctx context.Context, userID uint64) (*StatusDTO, error) { + if s == nil || s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.Activate(ctx, userID) +} + +func (s *Service) AdminSet(ctx context.Context, adminID uint64, userID uint64, req AdminSetRequest, meta AuditMeta) (*StatusDTO, error) { + if s == nil || s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.AdminSet(ctx, adminID, userID, req, meta) +} diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index 0fbac1b..3433df0 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -10,6 +10,7 @@ import ( "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/qualityowner" "hfb_sys/backend/internal/modules/rentergrowth" smsnode "hfb_sys/backend/internal/modules/sms" @@ -39,6 +40,7 @@ var defaultConfigs = []defaultConfig{ {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: qualityowner.ConfigKey, Value: qualityowner.DefaultConfigValue(), Description: "优质号主保证金与无忧租号保险费率配置 JSON"}, {Key: listingPublishAgreementsConfigKey, Value: defaultListingPublishAgreementsConfigValue(), Description: "发布账号前协议配置 JSON"}, {Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"}, {Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), Description: "租后须知配置 JSON"}, @@ -91,6 +93,7 @@ var adminVisibleConfigKeys = []string{ "order.pending_payment_timeout_minutes", "order.return_overdue_grace_minutes", "profile.post_rental_notice", + qualityowner.ConfigKey, "renter.growth_level_rules", "sms.business_templates", } diff --git a/backend/internal/modules/systemconfig/service_update.go b/backend/internal/modules/systemconfig/service_update.go index 657060c..f1e8a45 100644 --- a/backend/internal/modules/systemconfig/service_update.go +++ b/backend/internal/modules/systemconfig/service_update.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + "hfb_sys/backend/internal/modules/qualityowner" "hfb_sys/backend/internal/modules/rentergrowth" smsnode "hfb_sys/backend/internal/modules/sms" ) @@ -55,6 +56,17 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up } req.Value = value } + if key == qualityowner.ConfigKey { + cfg, err := qualityowner.ParseConfigValue(req.Value) + if err != nil { + return nil, ErrInvalidConfig + } + value, err := qualityowner.MarshalConfig(cfg) + if err != nil { + return nil, ErrInvalidConfig + } + req.Value = value + } return s.repo.Update(ctx, actorID, key, req, meta) } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 13dbdf6..e9ca7a1 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -35,6 +35,7 @@ import ( "hfb_sys/backend/internal/modules/paymentaccount" "hfb_sys/backend/internal/modules/paymentconfig" "hfb_sys/backend/internal/modules/pickup" + "hfb_sys/backend/internal/modules/qualityowner" "hfb_sys/backend/internal/modules/realname" "hfb_sys/backend/internal/modules/supportgroup" "hfb_sys/backend/internal/modules/systemconfig" @@ -165,6 +166,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { if deps.DB != nil { listingRepo = listing.NewRepository(deps.DB, chatCreator) } + var qualityOwnerRepo *qualityowner.Repository + if deps.DB != nil { + qualityOwnerRepo = qualityowner.NewRepository(deps.DB) + } var paymentRepo *payment.Repository var orderRepo *order.Repository if deps.DB != nil { @@ -267,6 +272,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { if mohongRepo != nil { paymentOpts = append(paymentOpts, payment.WithMohongRepo(mohongRepo)) } + if qualityOwnerRepo != nil { + paymentOpts = append(paymentOpts, payment.WithQualityOwnerRepo(qualityOwnerRepo)) + } paymentRepo = payment.NewRepository(deps.DB, paymentConfigRepo, orderRepo, paymentOpts...) } paymentService := payment.NewService(paymentRepo) @@ -309,6 +317,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } systemConfigService := systemconfig.NewService(systemConfigRepo) systemConfigHandler := systemconfig.NewHandler(systemConfigService) + qualityOwnerService := qualityowner.NewService(qualityOwnerRepo) + qualityOwnerHandler := qualityowner.NewHandler(qualityOwnerService) var distributionRepo *distribution.Repository if deps.DB != nil { distributionRepo = distribution.NewRepository(deps.DB, deps.Redis) @@ -477,6 +487,14 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { api.PUT("/me", requireAuth, userHandler.UpdateMe) api.PUT("/password", requireAuth, authHandler.SetPassword) + qualityOwnerRoutes := api.Group("/quality-owner", requireAuth) + { + qualityOwnerRoutes.GET("/status", qualityOwnerHandler.Status) + qualityOwnerRoutes.POST("/activate", requireRealname, qualityOwnerHandler.Activate) + qualityOwnerRoutes.POST("/payment", requireRealname, paymentHandler.StartQualityOwner) + qualityOwnerRoutes.GET("/payment", paymentHandler.QueryQualityOwner) + } + if invitationHandler != nil { api.GET("/invitations/code-info", invitationHandler.GetInviteInfo) api.GET("/invitations/code-info/:code", invitationHandler.GetInviteInfo) @@ -768,6 +786,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/users/:id/manual-realname", requirePerm("user:manual_realname"), adminUserHandler.ManualRealname) adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname) adminRoutes.POST("/users/:id/wallet/adjust", requirePerm("user:wallet_adjust"), adminUserHandler.AdjustWallet) + adminRoutes.POST("/users/:id/quality-owner", requirePerm("user:quality_owner"), qualityOwnerHandler.AdminSet) adminRoutes.GET("/orders", requirePerm("order:list"), orderHandler.AdminList) adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing) adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail) diff --git a/backend/migrations/000093_quality_owner_and_premium_listings.sql b/backend/migrations/000093_quality_owner_and_premium_listings.sql new file mode 100644 index 0000000..b4a9dad --- /dev/null +++ b/backend/migrations/000093_quality_owner_and_premium_listings.sql @@ -0,0 +1,73 @@ +-- +goose Up + +-- 优质号主与无忧租号保险: +-- users 保存优质号主资格及保证金快照; +-- rental_listings 标记极品专区商品,并保存发布时赔付方案/费率快照; +-- rental_orders / order_checkouts 保存下单时费率快照和实际保险服务费。 +ALTER TABLE users + ADD COLUMN quality_owner_status VARCHAR(16) NOT NULL DEFAULT 'none' COMMENT '优质号主状态: none普通/active优质' AFTER status, + ADD COLUMN quality_owner_deposit_cent BIGINT NOT NULL DEFAULT 0 COMMENT '优质号主保证金(分)' AFTER quality_owner_status, + ADD COLUMN quality_owner_deposit_source VARCHAR(16) NOT NULL DEFAULT '' COMMENT '保证金来源: paid用户缴纳/manual后台设置' AFTER quality_owner_deposit_cent, + ADD COLUMN quality_owner_activated_at DATETIME NULL COMMENT '优质号主开通时间' AFTER quality_owner_deposit_source, + ADD KEY idx_users_quality_owner_status (quality_owner_status); + +ALTER TABLE rental_listings + ADD COLUMN is_premium TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否极品专区' AFTER distribution_site_id, + ADD COLUMN compensation_plan VARCHAR(16) NOT NULL DEFAULT 'normal' COMMENT '赔付方案: normal普通赔付/insurance无忧租号保险' AFTER is_premium, + ADD COLUMN insurance_fee_bps INT NOT NULL DEFAULT 0 COMMENT '保险服务费率快照(万分比)' AFTER compensation_plan, + ADD KEY idx_rental_listings_premium_status (is_premium, status, review_status); + +ALTER TABLE rental_orders + ADD COLUMN is_premium TINYINT(1) NOT NULL DEFAULT 0 COMMENT '下单时是否极品专区' AFTER distribution_rule_version_id, + ADD COLUMN compensation_plan VARCHAR(16) NOT NULL DEFAULT 'normal' COMMENT '下单时赔付方案快照' AFTER is_premium, + ADD COLUMN insurance_fee_bps INT NOT NULL DEFAULT 0 COMMENT '下单时保险服务费率快照(万分比)' AFTER compensation_plan, + ADD COLUMN insurance_fee_cent BIGINT NOT NULL DEFAULT 0 COMMENT '实际保险服务费(分)' AFTER insurance_fee_bps; + +ALTER TABLE order_checkouts + ADD COLUMN insurance_fee_bps INT NOT NULL DEFAULT 0 COMMENT '保险服务费率快照(万分比)' AFTER platform_fee_cent, + ADD COLUMN insurance_fee_cent BIGINT NOT NULL DEFAULT 0 COMMENT '保险服务费(分)' AFTER insurance_fee_bps; + +INSERT INTO permissions (code, name, resource, action) VALUES +('user:quality_owner', '设置优质号主', 'user', 'quality_owner') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.code = 'user:quality_owner' +WHERE r.code IN ('ops', 'super_admin'); + +-- +goose Down + +DELETE rp +FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code = 'user:quality_owner'; + +DELETE FROM permissions WHERE code = 'user:quality_owner'; + +ALTER TABLE order_checkouts + DROP COLUMN insurance_fee_cent, + DROP COLUMN insurance_fee_bps; + +ALTER TABLE rental_orders + DROP COLUMN insurance_fee_cent, + DROP COLUMN insurance_fee_bps, + DROP COLUMN compensation_plan, + DROP COLUMN is_premium; + +ALTER TABLE rental_listings + DROP KEY idx_rental_listings_premium_status, + DROP COLUMN insurance_fee_bps, + DROP COLUMN compensation_plan, + DROP COLUMN is_premium; + +ALTER TABLE users + DROP KEY idx_users_quality_owner_status, + DROP COLUMN quality_owner_activated_at, + DROP COLUMN quality_owner_deposit_source, + DROP COLUMN quality_owner_deposit_cent, + DROP COLUMN quality_owner_status; diff --git a/backend/migrations/000094_quality_owner_payment.sql b/backend/migrations/000094_quality_owner_payment.sql new file mode 100644 index 0000000..cb83fb5 --- /dev/null +++ b/backend/migrations/000094_quality_owner_payment.sql @@ -0,0 +1,13 @@ +-- +goose Up + +-- 通用业务支付单:订单支付继续使用 order_id;优质号主保证金等非订单业务使用 biz_id, +-- 避免把用户 ID 写入订单外键语义。 +ALTER TABLE payment_orders + ADD COLUMN biz_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '业务对象ID(非订单业务使用)' AFTER order_no, + ADD KEY idx_payment_orders_user_biz (user_id, biz_type, biz_id, status); + +-- +goose Down + +ALTER TABLE payment_orders + DROP KEY idx_payment_orders_user_biz, + DROP COLUMN biz_id; diff --git a/frontend/src/features/admin/api/adminPayments.ts b/frontend/src/features/admin/api/adminPayments.ts index aea7133..6bb8f55 100644 --- a/frontend/src/features/admin/api/adminPayments.ts +++ b/frontend/src/features/admin/api/adminPayments.ts @@ -7,6 +7,7 @@ export interface AdminPayment { payment_no: string order_id: number order_no: string + biz_id?: number user_id: number user_phone: string provider: string diff --git a/frontend/src/features/admin/api/adminUsers.ts b/frontend/src/features/admin/api/adminUsers.ts index ad04a26..801a878 100644 --- a/frontend/src/features/admin/api/adminUsers.ts +++ b/frontend/src/features/admin/api/adminUsers.ts @@ -21,6 +21,10 @@ export interface AdminUserItem { renter_growth_level_name: string available_balance_cent: number frozen_balance_cent: number + quality_owner_status: 'none' | 'active' | string + quality_owner_deposit_cent: number + quality_owner_deposit_source: 'paid' | 'manual' | '' | string + quality_owner_activated_at?: string status: UserStatus order_count: number listing_count: number @@ -46,6 +50,7 @@ export interface AdminUserQuery { keyword?: string status?: '' | UserStatus has_deposit_free_quota?: boolean + quality_owner_only?: boolean } export async function fetchAdminUsers(page = 1, pageSize = 20, query: AdminUserQuery = {}) { @@ -53,6 +58,7 @@ export async function fetchAdminUsers(page = 1, pageSize = 20, query: AdminUserQ if (query.keyword) params.keyword = query.keyword if (query.status) params.status = query.status if (query.has_deposit_free_quota) params.has_deposit_free_quota = true + if (query.quality_owner_only) params.quality_owner_only = true const { data } = await apiClient.get>>( '/admin/users', { @@ -128,3 +134,14 @@ export async function adjustAdminUserWallet(id: number, payload: AdminWalletAdju ) return data.data } + +export async function setAdminUserQualityOwner( + id: number, + payload: { enabled: boolean; reason: string } +) { + const { data } = await apiClient.post>( + `/admin/users/${id}/quality-owner`, + payload + ) + return data.data +} diff --git a/frontend/src/features/admin/components/AdminUserDetailDrawer.vue b/frontend/src/features/admin/components/AdminUserDetailDrawer.vue index f470f83..941cb39 100644 --- a/frontend/src/features/admin/components/AdminUserDetailDrawer.vue +++ b/frontend/src/features/admin/components/AdminUserDetailDrawer.vue @@ -408,6 +408,7 @@ function paymentBizTypeLabel(type: string) { rent_refund: '租金退款', arbitration_refund: '仲裁退款', mohong_pay: '撞车支付', + quality_owner_pay: '优质号主保证金', mohong_refund: '撞车退款', } return labels[type] || type || '-' diff --git a/frontend/src/features/admin/components/QualityOwnerConfigDialog.vue b/frontend/src/features/admin/components/QualityOwnerConfigDialog.vue new file mode 100644 index 0000000..7b31daf --- /dev/null +++ b/frontend/src/features/admin/components/QualityOwnerConfigDialog.vue @@ -0,0 +1,122 @@ + + + diff --git a/frontend/src/features/admin/utils/adminOrderPresentation.ts b/frontend/src/features/admin/utils/adminOrderPresentation.ts index 779dab4..1f20c58 100644 --- a/frontend/src/features/admin/utils/adminOrderPresentation.ts +++ b/frontend/src/features/admin/utils/adminOrderPresentation.ts @@ -42,6 +42,7 @@ export function paymentBizTypeLabel(type: string) { admin_close_refund: '客服关闭退款', admin_seal_refund: '封存退款', deposit_refund: '暂扣押金归还', + quality_owner_pay: '优质号主保证金', } return labels[type] || type } diff --git a/frontend/src/features/admin/views/AdminChatsView.vue b/frontend/src/features/admin/views/AdminChatsView.vue index 72bd5a2..5abca11 100644 --- a/frontend/src/features/admin/views/AdminChatsView.vue +++ b/frontend/src/features/admin/views/AdminChatsView.vue @@ -648,6 +648,7 @@ function paymentBizTypeLabel(type: string) { admin_refund: '人工退款', cancel_refund: '取消退款', admin_close_refund: '客服关闭退款', + quality_owner_pay: '优质号主保证金', } return map[type] || type } diff --git a/frontend/src/features/admin/views/AdminListingsView.vue b/frontend/src/features/admin/views/AdminListingsView.vue index 1f47044..1bc7933 100644 --- a/frontend/src/features/admin/views/AdminListingsView.vue +++ b/frontend/src/features/admin/views/AdminListingsView.vue @@ -990,6 +990,20 @@ function formatQuantity(value: number) { {{ formatListingCode(row) }} + + + + + + diff --git a/frontend/src/features/admin/views/AdminPaymentsView.vue b/frontend/src/features/admin/views/AdminPaymentsView.vue index c08392c..092c8ab 100644 --- a/frontend/src/features/admin/views/AdminPaymentsView.vue +++ b/frontend/src/features/admin/views/AdminPaymentsView.vue @@ -37,7 +37,12 @@ const filters = reactive({ const payAmount = computed(() => payments.value - .filter(item => item.biz_type === 'order_pay' || item.biz_type === 'mohong_pay') + .filter( + item => + item.biz_type === 'order_pay' || + item.biz_type === 'mohong_pay' || + item.biz_type === 'quality_owner_pay' + ) .reduce((sum, item) => sum + Number(item.amount_cent || 0), 0) ) const refundAmount = computed(() => @@ -123,6 +128,7 @@ function bizTypeLabel(type: string) { rent_refund: '租金退款', arbitration_refund: '仲裁退款', mohong_pay: '撞车支付', + quality_owner_pay: '优质号主保证金', mohong_refund: '撞车退款', } return map[type] || type @@ -225,6 +231,7 @@ function jsonText(value: unknown) { > + diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.vue b/frontend/src/features/admin/views/AdminSystemConfigsView.vue index 2cf5cdf..577645a 100644 --- a/frontend/src/features/admin/views/AdminSystemConfigsView.vue +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.vue @@ -51,6 +51,7 @@ import PostRentalNoticeDialog from '../components/PostRentalNoticeDialog.vue' import GeneralConfigDialog from '../components/GeneralConfigDialog.vue' import RenterGrowthConfigDialog from '../components/RenterGrowthConfigDialog.vue' import SmsNodeTemplatesDialog from '../components/SmsNodeTemplatesDialog.vue' +import QualityOwnerConfigDialog from '../components/QualityOwnerConfigDialog.vue' import AutoWelcomeConfig from '../components/AutoWelcomeConfig.vue' import { fetchAutoWelcomeMessage, @@ -88,6 +89,7 @@ const currentEditingConfig = ref(null) // 各种子 Dialog 的可见性控制 const publishVisible = ref(false) const salePriceVisible = ref(false) +const qualityOwnerVisible = ref(false) const announcementsVisible = ref(false) const bannersVisible = ref(false) const awRecycleVisible = ref(false) @@ -105,6 +107,9 @@ const publishConfig = computed( const salePriceConfig = computed( () => configs.value.find(item => item.key === 'listing.sale_price_config') || null ) +const qualityOwnerConfig = computed( + () => configs.value.find(item => item.key === 'quality_owner.rules') || null +) const listingPublishCooldownConfig = computed( () => configs.value.find(item => item.key === 'listing.publish_cooldown_minutes') || null ) @@ -176,6 +181,7 @@ const regularConfigs = computed(() => item => item.key !== 'listing.publish_options' && item.key !== 'listing.sale_price_config' && + item.key !== 'quality_owner.rules' && item.key !== 'listing.publish_cooldown_minutes' && item.key !== 'mobile.home_announcements' && item.key !== 'mobile.home_banners' && @@ -220,6 +226,19 @@ const salePriceStats = computed(() => { } }) +const qualityOwnerStats = computed(() => { + try { + const parsed = JSON.parse(qualityOwnerConfig.value?.value || '{}') + return { + deposit: Number(parsed.deposit_amount_cent || 20000) / 100, + insurance: Number(parsed.insurance_fee_bps || 800) / 100, + premium: Number(parsed.premium_insurance_fee_bps || 400) / 100, + } + } catch { + return { deposit: 200, insurance: 8, premium: 4 } + } +}) + const homeStats = computed(() => { const announcements = parseHomeAnnouncements(homeAnnouncementsConfig.value?.value || '') const banners = parseHomeBanners(homeBannersConfig.value?.value || '') @@ -343,6 +362,8 @@ function openEdit(row: SystemConfig) { publishVisible.value = true } else if (row.key === 'listing.sale_price_config') { salePriceVisible.value = true + } else if (row.key === 'quality_owner.rules') { + qualityOwnerVisible.value = true } else if (row.key === 'mobile.home_announcements') { announcementsVisible.value = true } else if (row.key === 'mobile.home_banners') { @@ -633,6 +654,43 @@ function formatConfigValue(row: SystemConfig) { +
+
+
+

Premium Owner

+

优质号主与无忧租号保险

+ + 配置优质号主保证金,以及普通发布和「优质号主 + 极品专区」的无忧租号保险服务费率。 + +
+ + 编辑保证金与费率 + +
+
+
+ ¥{{ qualityOwnerStats.deposit }} + 优质号主保证金 +
+
+ {{ qualityOwnerStats.insurance }}% + 普通保险费率 +
+
+ {{ qualityOwnerStats.premium }}% + 极品专区专享费率 +
+
+ 订单快照 + 历史订单不受调价影响 +
+
+ 更新 + {{ formatHomeConfigStatus(qualityOwnerConfig, '未初始化') }} +
+
+
+
@@ -1009,6 +1067,13 @@ function formatConfigValue(row: SystemConfig) { @saved="loadConfigs" /> + + adminSession.hasPermission('user:wallet_adjust')) const canAdjustGrowthPoints = computed(() => adminSession.hasPermission('user:growth_points')) +const canSetQualityOwner = computed(() => adminSession.hasPermission('user:quality_owner')) const canViewUserDetail = computed( () => adminSession.hasPermission('wallet:view') || @@ -65,6 +67,7 @@ const filters = reactive({ keyword: '', status: '', has_deposit_free_quota: false, + quality_owner_only: false, }) const { @@ -87,6 +90,7 @@ function resetFilters() { filters.keyword = '' filters.status = '' filters.has_deposit_free_quota = false + filters.quality_owner_only = false queryUsers() } @@ -317,6 +321,40 @@ async function handleWalletAdjust() { } } +async function handleToggleQualityOwner(row: AdminUserItem) { + const enabling = row.quality_owner_status !== 'active' + try { + const { value } = await ElMessageBox.prompt( + enabling + ? '后台手动开通将直接发放优质号主资格并跳过保证金缴纳,请填写原因。' + : '取消后用户将无法继续发布到极品专区,已发布的极品商品会下架;用户自缴保证金将退回可用余额。', + enabling ? '设置优质号主' : '取消优质号主', + { + inputPlaceholder: '请填写 2-200 字操作原因', + inputValidator: value => { + const length = value.trim().length + return (length >= 2 && length <= 200) || '请填写 2-200 字操作原因' + }, + confirmButtonText: enabling ? '确认开通' : '确认取消', + cancelButtonText: '取消', + type: 'warning', + } + ) + submitting.value = true + await setAdminUserQualityOwner(row.id, { + enabled: enabling, + reason: value.trim(), + }) + ElMessage.success(enabling ? '已设置为优质号主' : '已取消优质号主') + await loadUsers() + } catch (error) { + if (error === 'cancel' || error === 'close') return + ElMessage.error(readError(error, '设置优质号主失败')) + } finally { + submitting.value = false + } +} + function moneyCent(value: number | string | undefined) { return formatCent(Number(value || 0)) } @@ -363,6 +401,11 @@ function moneyCent(value: number | string | undefined) { 仅看有额度 + + + 仅看优质号主 + + 查询 + + +