feat(quality-owner): 增加优质号主、极品专区与无忧租号保险
- 支持保证金余额支付、在线支付开通及后台手动设置审计 - 新增极品专区发布权限、赔付方案与保险结算费率快照 - 优化首页、商品卡和个人中心的优质号主身份展示
This commit is contained in:
@@ -43,6 +43,9 @@ type RentalListing struct {
|
|||||||
ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"`
|
ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"`
|
||||||
DistributionSiteID *uint64 `gorm:"column:distribution_site_id;index" json:"distribution_site_id,omitempty"`
|
DistributionSiteID *uint64 `gorm:"column:distribution_site_id;index" json:"distribution_site_id,omitempty"`
|
||||||
SourceChannel string `gorm:"size:64;not null;default:''" json:"-"`
|
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"`
|
ReviewReason string `gorm:"size:255;not null;default:''" json:"review_reason"`
|
||||||
PublishedAt *time.Time `json:"published_at"`
|
PublishedAt *time.Time `json:"published_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ type RentalOrder struct {
|
|||||||
DistributionSiteID *uint64 `gorm:"index" json:"distribution_site_id,omitempty"`
|
DistributionSiteID *uint64 `gorm:"index" json:"distribution_site_id,omitempty"`
|
||||||
DistributionMode string `gorm:"size:32;not null;default:'';index" json:"distribution_mode"`
|
DistributionMode string `gorm:"size:32;not null;default:'';index" json:"distribution_mode"`
|
||||||
DistributionRuleVersionID *uint64 `gorm:"index" json:"distribution_rule_version_id,omitempty"`
|
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"`
|
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
|
||||||
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_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"`
|
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ type OrderCheckout struct {
|
|||||||
RentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
RentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
PlatformFeeCent 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:"-"`
|
DepositAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
PureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
PureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
PureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"`
|
PureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type PaymentOrder struct {
|
|||||||
PaymentNo string `gorm:"size:64;not null;uniqueIndex" json:"payment_no"`
|
PaymentNo string `gorm:"size:64;not null;uniqueIndex" json:"payment_no"`
|
||||||
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
||||||
OrderNo string `gorm:"size:64;not null;index" json:"order_no"`
|
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"`
|
UserID uint64 `gorm:"not null;index" json:"user_id"`
|
||||||
PaymentConfigID uint64 `gorm:"not null;default:0;index" json:"payment_config_id"`
|
PaymentConfigID uint64 `gorm:"not null;default:0;index" json:"payment_config_id"`
|
||||||
Provider string `gorm:"size:32;not null" json:"provider"`
|
Provider string `gorm:"size:32;not null" json:"provider"`
|
||||||
|
|||||||
@@ -3,18 +3,22 @@ package model
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"`
|
Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"`
|
||||||
PasswordHash string `gorm:"size:255;not null;default:''" json:"-"`
|
PasswordHash string `gorm:"size:255;not null;default:''" json:"-"`
|
||||||
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
||||||
AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"`
|
AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"`
|
||||||
RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"`
|
RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"`
|
||||||
RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"`
|
RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"`
|
||||||
CreditScore int `gorm:"not null;default:100" json:"credit_score"`
|
CreditScore int `gorm:"not null;default:100" json:"credit_score"`
|
||||||
DepositFreeQuotaCent int64 `gorm:"not null;default:0" json:"-"`
|
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"`
|
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"`
|
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"`
|
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。
|
// 默认值对齐迁移 000052 的 users.token_version DEFAULT 0。
|
||||||
TokenVersion int64 `gorm:"not null;default:0" json:"-"`
|
TokenVersion int64 `gorm:"not null;default:0" json:"-"`
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ type UserDTO struct {
|
|||||||
RenterGrowthLevelName string `json:"renter_growth_level_name"`
|
RenterGrowthLevelName string `json:"renter_growth_level_name"`
|
||||||
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
||||||
FrozenBalanceCent int64 `json:"frozen_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"`
|
Status string `json:"status"`
|
||||||
OrderCount int64 `json:"order_count"`
|
OrderCount int64 `json:"order_count"`
|
||||||
ListingCount int64 `json:"listing_count"`
|
ListingCount int64 `json:"listing_count"`
|
||||||
@@ -65,6 +69,7 @@ type ListQuery struct {
|
|||||||
Keyword string
|
Keyword string
|
||||||
Status string
|
Status string
|
||||||
HasDepositFreeQuota bool
|
HasDepositFreeQuota bool
|
||||||
|
QualityOwnerOnly bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type PaginatedResult struct {
|
type PaginatedResult struct {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func (h *Handler) List(c *gin.Context) {
|
|||||||
Keyword: strings.TrimSpace(c.Query("keyword")),
|
Keyword: strings.TrimSpace(c.Query("keyword")),
|
||||||
Status: strings.TrimSpace(c.Query("status")),
|
Status: strings.TrimSpace(c.Query("status")),
|
||||||
HasDepositFreeQuota: parseBoolQuery(c.Query("has_deposit_free_quota")),
|
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)
|
result, err := h.service.List(c.Request.Context(), page, pageSize, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ func applyUserFilter(tx *gorm.DB, query ListQuery, growthConfig rentergrowth.Con
|
|||||||
tx = tx.Where("u.deposit_free_quota_cent > 0")
|
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 keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||||
if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) {
|
if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) {
|
||||||
// 11 位手机号:命中 users(phone) 唯一索引
|
// 11 位手机号:命中 users(phone) 唯一索引
|
||||||
@@ -563,6 +566,10 @@ func (row userRow) toDTO(growthConfig rentergrowth.Config) UserDTO {
|
|||||||
RenterGrowthLevelName: level.Name,
|
RenterGrowthLevelName: level.Name,
|
||||||
AvailableBalanceCent: row.AvailableBalanceCent,
|
AvailableBalanceCent: row.AvailableBalanceCent,
|
||||||
FrozenBalanceCent: row.FrozenBalanceCent,
|
FrozenBalanceCent: row.FrozenBalanceCent,
|
||||||
|
QualityOwnerStatus: row.QualityOwnerStatus,
|
||||||
|
QualityOwnerDepositCent: row.QualityOwnerDepositCent,
|
||||||
|
QualityOwnerDepositSource: row.QualityOwnerDepositSource,
|
||||||
|
QualityOwnerActivatedAt: row.QualityOwnerActivatedAt,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
OrderCount: row.OrderCount,
|
OrderCount: row.OrderCount,
|
||||||
ListingCount: row.ListingCount,
|
ListingCount: row.ListingCount,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type ListingDTO struct {
|
|||||||
OwnerID uint64 `json:"owner_id"`
|
OwnerID uint64 `json:"owner_id"`
|
||||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||||
OwnerNickname string `json:"owner_nickname,omitempty"`
|
OwnerNickname string `json:"owner_nickname,omitempty"`
|
||||||
|
OwnerIsQualityOwner bool `json:"owner_is_quality_owner"`
|
||||||
SourceChannel string `json:"source_channel,omitempty"`
|
SourceChannel string `json:"source_channel,omitempty"`
|
||||||
IsExternalUpload bool `json:"is_external_upload"`
|
IsExternalUpload bool `json:"is_external_upload"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -41,6 +42,9 @@ type ListingDTO struct {
|
|||||||
DistributionSiteID *uint64 `json:"distribution_site_id,omitempty"`
|
DistributionSiteID *uint64 `json:"distribution_site_id,omitempty"`
|
||||||
DistributionSiteCode string `json:"distribution_site_code,omitempty"`
|
DistributionSiteCode string `json:"distribution_site_code,omitempty"`
|
||||||
DistributionSiteName string `json:"distribution_site_name,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"`
|
ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -57,6 +61,9 @@ type CreateRequest struct {
|
|||||||
ScreenshotURLS []string `json:"screenshot_urls"`
|
ScreenshotURLS []string `json:"screenshot_urls"`
|
||||||
PriceCent int64 `json:"price_cent"`
|
PriceCent int64 `json:"price_cent"`
|
||||||
DepositAmountCent int64 `json:"deposit_amount_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"`
|
AgreedVirtualAssetSale bool `json:"agreed_virtual_asset_sale"`
|
||||||
AgreedSellerAgreement bool `json:"agreed_seller_agreement"`
|
AgreedSellerAgreement bool `json:"agreed_seller_agreement"`
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ func writeListingError(c *gin.Context, err error) {
|
|||||||
response.BadRequest(c, "发布比例必须在参考比例至参考比例加 10 之间")
|
response.BadRequest(c, "发布比例必须在参考比例至参考比例加 10 之间")
|
||||||
case errors.Is(err, ErrDistributionSiteInvalid):
|
case errors.Is(err, ErrDistributionSiteInvalid):
|
||||||
response.BadRequest(c, "分销站点无效或已停用")
|
response.BadRequest(c, "分销站点无效或已停用")
|
||||||
|
case errors.Is(err, ErrPremiumOwnerRequired):
|
||||||
|
response.Error(c, http.StatusForbidden, "quality_owner_required", "仅优质号主可以发布到极品专区")
|
||||||
case isUploadValidationError(err):
|
case isUploadValidationError(err):
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
case isFireLevelTooLow(err):
|
case isFireLevelTooLow(err):
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque
|
|||||||
OwnerID: ownerID,
|
OwnerID: ownerID,
|
||||||
PriceCent: priceCent,
|
PriceCent: priceCent,
|
||||||
DepositAmountCent: req.DepositAmountCent,
|
DepositAmountCent: req.DepositAmountCent,
|
||||||
|
IsPremium: req.IsPremium,
|
||||||
|
CompensationPlan: req.CompensationPlan,
|
||||||
|
InsuranceFeeBps: req.InsuranceFeeBps,
|
||||||
Status: listingStatus,
|
Status: listingStatus,
|
||||||
ReviewStatus: reviewStatus,
|
ReviewStatus: reviewStatus,
|
||||||
PublishedAt: publishedAt,
|
PublishedAt: publishedAt,
|
||||||
@@ -289,6 +292,9 @@ func (r *Repository) Update(ctx context.Context, ownerID uint64, listingID uint6
|
|||||||
|
|
||||||
listing.PriceCent = normalizedListingPriceCent(req)
|
listing.PriceCent = normalizedListingPriceCent(req)
|
||||||
listing.DepositAmountCent = req.DepositAmountCent
|
listing.DepositAmountCent = req.DepositAmountCent
|
||||||
|
listing.IsPremium = req.IsPremium
|
||||||
|
listing.CompensationPlan = req.CompensationPlan
|
||||||
|
listing.InsuranceFeeBps = req.InsuranceFeeBps
|
||||||
fromStatus := listing.Status
|
fromStatus := listing.Status
|
||||||
listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired)
|
listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired)
|
||||||
listing.Status = listingStatus
|
listing.Status = listingStatus
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type listingRow struct {
|
|||||||
Title string
|
Title string
|
||||||
OwnerPhone string
|
OwnerPhone string
|
||||||
OwnerNickname string
|
OwnerNickname string
|
||||||
|
OwnerIsQualityOwner bool `gorm:"column:owner_is_quality_owner"`
|
||||||
DisplaySourceChannel string `gorm:"column:display_source_channel"`
|
DisplaySourceChannel string `gorm:"column:display_source_channel"`
|
||||||
IsExternalUpload bool `gorm:"column:is_external_upload"`
|
IsExternalUpload bool `gorm:"column:is_external_upload"`
|
||||||
DistributionSiteCode string `gorm:"column:distribution_site_code"`
|
DistributionSiteCode string `gorm:"column:distribution_site_code"`
|
||||||
@@ -171,6 +172,7 @@ func (row listingRow) toDTO() ListingDTO {
|
|||||||
OwnerID: row.OwnerID,
|
OwnerID: row.OwnerID,
|
||||||
OwnerPhone: row.OwnerPhone,
|
OwnerPhone: row.OwnerPhone,
|
||||||
OwnerNickname: row.OwnerNickname,
|
OwnerNickname: row.OwnerNickname,
|
||||||
|
OwnerIsQualityOwner: row.OwnerIsQualityOwner,
|
||||||
SourceChannel: row.DisplaySourceChannel,
|
SourceChannel: row.DisplaySourceChannel,
|
||||||
IsExternalUpload: row.IsExternalUpload,
|
IsExternalUpload: row.IsExternalUpload,
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
@@ -195,6 +197,9 @@ func (row listingRow) toDTO() ListingDTO {
|
|||||||
DistributionSiteID: row.DistributionSiteID,
|
DistributionSiteID: row.DistributionSiteID,
|
||||||
DistributionSiteCode: row.DistributionSiteCode,
|
DistributionSiteCode: row.DistributionSiteCode,
|
||||||
DistributionSiteName: row.DistributionSiteName,
|
DistributionSiteName: row.DistributionSiteName,
|
||||||
|
IsPremium: row.IsPremium,
|
||||||
|
CompensationPlan: normalizedCompensationPlan(row.CompensationPlan),
|
||||||
|
InsuranceFeeBps: row.InsuranceFeeBps,
|
||||||
ReviewReason: reviewReason,
|
ReviewReason: reviewReason,
|
||||||
PublishedAt: row.PublishedAt,
|
PublishedAt: row.PublishedAt,
|
||||||
CreatedAt: row.CreatedAt,
|
CreatedAt: row.CreatedAt,
|
||||||
@@ -231,6 +236,9 @@ func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO {
|
|||||||
SettlementMode: listing.SettlementMode,
|
SettlementMode: listing.SettlementMode,
|
||||||
ManagedAdminID: listing.ManagedAdminID,
|
ManagedAdminID: listing.ManagedAdminID,
|
||||||
DistributionSiteID: listing.DistributionSiteID,
|
DistributionSiteID: listing.DistributionSiteID,
|
||||||
|
IsPremium: listing.IsPremium,
|
||||||
|
CompensationPlan: normalizedCompensationPlan(listing.CompensationPlan),
|
||||||
|
InsuranceFeeBps: listing.InsuranceFeeBps,
|
||||||
ReviewReason: reviewReason,
|
ReviewReason: reviewReason,
|
||||||
PublishedAt: listing.PublishedAt,
|
PublishedAt: listing.PublishedAt,
|
||||||
CreatedAt: listing.CreatedAt,
|
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) {
|
func normalizedReviewState(status string, reviewStatus string, reviewReason string) (string, string) {
|
||||||
if status == "offline" {
|
if status == "offline" {
|
||||||
return "none", reviewReason
|
return "none", reviewReason
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ func matchesPublicZone(item ListingDTO, zone string) bool {
|
|||||||
return strings.Contains(item.LoginPlatform, "账密") || strings.Contains(item.LoginPlatform, "账号密码")
|
return strings.Contains(item.LoginPlatform, "账密") || strings.Contains(item.LoginPlatform, "账号密码")
|
||||||
case "highCoin":
|
case "highCoin":
|
||||||
return coinMFromListing(item) >= 100
|
return coinMFromListing(item) >= 100
|
||||||
|
case "premium":
|
||||||
|
return item.IsPremium
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -198,9 +200,10 @@ func publicZoneCounts(items []ListingDTO) map[string]int64 {
|
|||||||
"night": 0,
|
"night": 0,
|
||||||
"password": 0,
|
"password": 0,
|
||||||
"highCoin": 0,
|
"highCoin": 0,
|
||||||
|
"premium": 0,
|
||||||
}
|
}
|
||||||
for _, item := range items {
|
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) {
|
if matchesPublicZone(item, zone) {
|
||||||
counts[zone]++
|
counts[zone]++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestFilterPublicListingsRegionMultiSelectRequiresAll(t *testing.T) {
|
||||||
items := []ListingDTO{
|
items := []ListingDTO{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int
|
|||||||
|
|
||||||
var rows []publicZoneRow
|
var rows []publicZoneRow
|
||||||
err := r.db.WithContext(ctx).Table("rental_listings AS l").
|
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").
|
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).
|
Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false).
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
@@ -199,6 +199,7 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int
|
|||||||
"night": 0,
|
"night": 0,
|
||||||
"password": 0,
|
"password": 0,
|
||||||
"highCoin": 0,
|
"highCoin": 0,
|
||||||
|
"premium": 0,
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
summary := decodeAssetSummary(row.AssetSummary)
|
summary := decodeAssetSummary(row.AssetSummary)
|
||||||
@@ -217,6 +218,9 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int
|
|||||||
if float64(row.HafCoinAmount)/1000000 >= 100 {
|
if float64(row.HafCoinAmount)/1000000 >= 100 {
|
||||||
counts["highCoin"]++
|
counts["highCoin"]++
|
||||||
}
|
}
|
||||||
|
if row.IsPremium {
|
||||||
|
counts["premium"]++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
r.publicZoneCounts = publicZoneCountCache{
|
r.publicZoneCounts = publicZoneCountCache{
|
||||||
Counts: counts,
|
Counts: counts,
|
||||||
@@ -319,4 +323,5 @@ type publicZoneRow struct {
|
|||||||
LoginPlatform string
|
LoginPlatform string
|
||||||
HafCoinAmount int64
|
HafCoinAmount int64
|
||||||
AssetSummary datatypes.JSON `gorm:"column:asset_summary"`
|
AssetSummary datatypes.JSON `gorm:"column:asset_summary"`
|
||||||
|
IsPremium bool `gorm:"column:is_premium"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -225,6 +225,7 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
|||||||
return r.db.WithContext(ctx).Table("rental_listings AS l").
|
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,
|
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,
|
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,
|
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_code, '') AS distribution_site_code,
|
||||||
COALESCE(src_site.site_name, '') AS distribution_site_name,
|
COALESCE(src_site.site_name, '') AS distribution_site_name,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package listing
|
package listing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -57,6 +58,20 @@ func (r *Repository) syncPublishedShelf(tx *gorm.DB, listing *model.RentalListin
|
|||||||
return r.shelfSyncer.EnsureListingShelvedTx(tx, listing.ID, listing.AccountID)
|
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 {
|
type publicZoneCountCache struct {
|
||||||
Counts map[string]int64
|
Counts map[string]int64
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ func (r *Repository) TransferOwner(ctx context.Context, adminID uint64, listingI
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if listing.IsPremium && target.QualityOwnerStatus != "active" {
|
||||||
|
return ErrPremiumOwnerRequired
|
||||||
|
}
|
||||||
|
|
||||||
beforeOwnerID := listing.OwnerID
|
beforeOwnerID := listing.OwnerID
|
||||||
listing.OwnerID = target.ID
|
listing.OwnerID = target.ID
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ var (
|
|||||||
ErrExternalAdjustment = errors.New("invalid external listing adjustment")
|
ErrExternalAdjustment = errors.New("invalid external listing adjustment")
|
||||||
ErrSaleRatioOutOfRange = errors.New("sale ratio out of range")
|
ErrSaleRatioOutOfRange = errors.New("sale ratio out of range")
|
||||||
ErrDistributionSiteInvalid = errors.New("invalid distribution site")
|
ErrDistributionSiteInvalid = errors.New("invalid distribution site")
|
||||||
|
ErrPremiumOwnerRequired = errors.New("premium listing requires quality owner")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -52,6 +53,8 @@ const (
|
|||||||
defaultPublishCooldownMinutes = 5
|
defaultPublishCooldownMinutes = 5
|
||||||
maxExternalUploadItems = 10
|
maxExternalUploadItems = 10
|
||||||
defaultUploadScreenshot = "/api/listings/default-upload-screenshot?v=webp1"
|
defaultUploadScreenshot = "/api/listings/default-upload-screenshot?v=webp1"
|
||||||
|
CompensationPlanNormal = "normal"
|
||||||
|
CompensationPlanInsurance = "insurance"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PublishCooldownError struct {
|
type PublishCooldownError struct {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package listing
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/modules/qualityowner"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest, siteCode ...string) (*ListingDTO, error) {
|
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 {
|
if err := validateRequiredOnlineTime(req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
cooldown, err := s.publishCooldown(ctx)
|
cooldown, err := s.publishCooldown(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err := validateRequiredOnlineTime(req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
reviewRequired, err := s.reviewRequired(ctx)
|
reviewRequired, err := s.reviewRequired(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
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) {
|
func (s *Service) SubmitReview(ctx context.Context, ownerID uint64, id uint64) (*ListingDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
|||||||
order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent
|
order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent
|
||||||
order.ActualPureCoinDiscountCent = settlement.PureCoinDiscountCent
|
order.ActualPureCoinDiscountCent = settlement.PureCoinDiscountCent
|
||||||
order.ActualExtraItemAmountCent = settlement.ExtraItemAmountCent
|
order.ActualExtraItemAmountCent = settlement.ExtraItemAmountCent
|
||||||
|
order.InsuranceFeeCent = settlement.InsuranceFeeCent
|
||||||
if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil {
|
if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -140,6 +141,8 @@ func applyCheckoutSettlement(checkout *model.OrderCheckout, settlement checkoutS
|
|||||||
checkout.RentAmountCent = settlement.ActualRentAmountCent
|
checkout.RentAmountCent = settlement.ActualRentAmountCent
|
||||||
checkout.OwnerRentAmountCent = settlement.OwnerRentIncomeCent
|
checkout.OwnerRentAmountCent = settlement.OwnerRentIncomeCent
|
||||||
checkout.PlatformFeeCent = settlement.PlatformFeeCent
|
checkout.PlatformFeeCent = settlement.PlatformFeeCent
|
||||||
|
checkout.InsuranceFeeBps = settlement.InsuranceFeeBps
|
||||||
|
checkout.InsuranceFeeCent = settlement.InsuranceFeeCent
|
||||||
checkout.PureCoinAmountCent = settlement.PureCoinAmountCent
|
checkout.PureCoinAmountCent = settlement.PureCoinAmountCent
|
||||||
checkout.PureCoinDiscountCent = settlement.PureCoinDiscountCent
|
checkout.PureCoinDiscountCent = settlement.PureCoinDiscountCent
|
||||||
checkout.PureCoinPayableCent = settlement.PureCoinPayableCent
|
checkout.PureCoinPayableCent = settlement.PureCoinPayableCent
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ type OrderDTO struct {
|
|||||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||||
AccountSource string `json:"account_source"`
|
AccountSource string `json:"account_source"`
|
||||||
SourceChannel string `json:"source_channel,omitempty"`
|
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"`
|
Status string `json:"status"`
|
||||||
HandoffStatus string `json:"handoff_status"`
|
HandoffStatus string `json:"handoff_status"`
|
||||||
HandoffMode string `json:"handoff_mode"`
|
HandoffMode string `json:"handoff_mode"`
|
||||||
@@ -330,6 +334,8 @@ type CheckoutDTO struct {
|
|||||||
RentAmountCent *int64 `json:"rent_amount_cent,omitempty"`
|
RentAmountCent *int64 `json:"rent_amount_cent,omitempty"`
|
||||||
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
|
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
|
||||||
PlatformFeeCent *int64 `json:"platform_fee_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"`
|
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||||
PureCoinAmountCent int64 `json:"pure_coin_amount_cent"`
|
PureCoinAmountCent int64 `json:"pure_coin_amount_cent"`
|
||||||
PureCoinDiscountCent int64 `json:"pure_coin_discount_cent"`
|
PureCoinDiscountCent int64 `json:"pure_coin_discount_cent"`
|
||||||
|
|||||||
@@ -151,6 +151,9 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
|||||||
AccountSnapshot: snapshot,
|
AccountSnapshot: snapshot,
|
||||||
AccountSource: accountSource,
|
AccountSource: accountSource,
|
||||||
SourceChannel: sourceChannel,
|
SourceChannel: sourceChannel,
|
||||||
|
IsPremium: listing.IsPremium,
|
||||||
|
CompensationPlan: normalizedCompensationPlan(listing.CompensationPlan),
|
||||||
|
InsuranceFeeBps: listing.InsuranceFeeBps,
|
||||||
Status: orderStatusPendingPayment,
|
Status: orderStatusPendingPayment,
|
||||||
HandoffStatus: handoffStatusNone,
|
HandoffStatus: handoffStatusNone,
|
||||||
HandoffMode: listingHandoffMode(listing),
|
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)
|
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) {
|
func loadShelfRatioTx(tx *gorm.DB, siteCode string, listingID uint64) (float64, uint64, error) {
|
||||||
siteCode = strings.TrimSpace(siteCode)
|
siteCode = strings.TrimSpace(siteCode)
|
||||||
if siteCode == "" || listingID == 0 {
|
if siteCode == "" || listingID == 0 {
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
|||||||
AccountSnapshot: row.AccountSnapshot,
|
AccountSnapshot: row.AccountSnapshot,
|
||||||
AccountSource: effectiveAccountSource(row.RentalOrder),
|
AccountSource: effectiveAccountSource(row.RentalOrder),
|
||||||
SourceChannel: row.SourceChannel,
|
SourceChannel: row.SourceChannel,
|
||||||
|
IsPremium: row.IsPremium,
|
||||||
|
CompensationPlan: normalizedCompensationPlan(row.CompensationPlan),
|
||||||
|
InsuranceFeeBps: row.InsuranceFeeBps,
|
||||||
|
InsuranceFeeCent: row.InsuranceFeeCent,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
HandoffStatus: row.HandoffStatus,
|
HandoffStatus: row.HandoffStatus,
|
||||||
HandoffMode: effectiveHandoffMode(row.RentalOrder),
|
HandoffMode: effectiveHandoffMode(row.RentalOrder),
|
||||||
@@ -312,6 +316,8 @@ func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO {
|
|||||||
RentAmountCent: &rentAmountCent,
|
RentAmountCent: &rentAmountCent,
|
||||||
OwnerRentAmountCent: &ownerRentAmountCent,
|
OwnerRentAmountCent: &ownerRentAmountCent,
|
||||||
PlatformFeeCent: &platformFeeCent,
|
PlatformFeeCent: &platformFeeCent,
|
||||||
|
InsuranceFeeBps: checkout.InsuranceFeeBps,
|
||||||
|
InsuranceFeeCent: checkout.InsuranceFeeCent,
|
||||||
DepositAmountCent: checkout.DepositAmountCent,
|
DepositAmountCent: checkout.DepositAmountCent,
|
||||||
PureCoinAmountCent: checkout.PureCoinAmountCent,
|
PureCoinAmountCent: checkout.PureCoinAmountCent,
|
||||||
PureCoinDiscountCent: checkout.PureCoinDiscountCent,
|
PureCoinDiscountCent: checkout.PureCoinDiscountCent,
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ type checkoutSettlement struct {
|
|||||||
DepositRefundCent int64
|
DepositRefundCent int64
|
||||||
RenterRefundCent int64
|
RenterRefundCent int64
|
||||||
PlatformFeeCent int64
|
PlatformFeeCent int64
|
||||||
|
InsuranceFeeBps int
|
||||||
|
InsuranceFeeCent int64
|
||||||
ActualRentAmountCent int64
|
ActualRentAmountCent int64
|
||||||
PureCoinAmountCent int64
|
PureCoinAmountCent int64
|
||||||
PureCoinDiscountCent int64
|
PureCoinDiscountCent int64
|
||||||
@@ -59,6 +61,8 @@ type ActualCheckoutSettlement struct {
|
|||||||
DepositRefundCent int64
|
DepositRefundCent int64
|
||||||
RenterRefundCent int64
|
RenterRefundCent int64
|
||||||
PlatformFeeCent int64
|
PlatformFeeCent int64
|
||||||
|
InsuranceFeeBps int
|
||||||
|
InsuranceFeeCent int64
|
||||||
ActualRentAmountCent int64
|
ActualRentAmountCent int64
|
||||||
PureCoinAmountCent int64
|
PureCoinAmountCent int64
|
||||||
PureCoinDiscountCent int64
|
PureCoinDiscountCent int64
|
||||||
@@ -94,6 +98,8 @@ func toActualCheckoutSettlement(settlement checkoutSettlement) ActualCheckoutSet
|
|||||||
DepositRefundCent: settlement.DepositRefundCent,
|
DepositRefundCent: settlement.DepositRefundCent,
|
||||||
RenterRefundCent: settlement.RenterRefundCent,
|
RenterRefundCent: settlement.RenterRefundCent,
|
||||||
PlatformFeeCent: settlement.PlatformFeeCent,
|
PlatformFeeCent: settlement.PlatformFeeCent,
|
||||||
|
InsuranceFeeBps: settlement.InsuranceFeeBps,
|
||||||
|
InsuranceFeeCent: settlement.InsuranceFeeCent,
|
||||||
ActualRentAmountCent: settlement.ActualRentAmountCent,
|
ActualRentAmountCent: settlement.ActualRentAmountCent,
|
||||||
PureCoinAmountCent: settlement.PureCoinAmountCent,
|
PureCoinAmountCent: settlement.PureCoinAmountCent,
|
||||||
PureCoinDiscountCent: settlement.PureCoinDiscountCent,
|
PureCoinDiscountCent: settlement.PureCoinDiscountCent,
|
||||||
@@ -273,6 +279,8 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c
|
|||||||
RentAmountCent: settlement.ActualRentAmountCent,
|
RentAmountCent: settlement.ActualRentAmountCent,
|
||||||
OwnerRentAmountCent: settlement.OwnerRentIncomeCent,
|
OwnerRentAmountCent: settlement.OwnerRentIncomeCent,
|
||||||
PlatformFeeCent: settlement.PlatformFeeCent,
|
PlatformFeeCent: settlement.PlatformFeeCent,
|
||||||
|
InsuranceFeeBps: settlement.InsuranceFeeBps,
|
||||||
|
InsuranceFeeCent: settlement.InsuranceFeeCent,
|
||||||
DepositAmountCent: order.DepositAmountCent,
|
DepositAmountCent: order.DepositAmountCent,
|
||||||
PureCoinAmountCent: settlement.PureCoinAmountCent,
|
PureCoinAmountCent: settlement.PureCoinAmountCent,
|
||||||
PureCoinDiscountCent: settlement.PureCoinDiscountCent,
|
PureCoinDiscountCent: settlement.PureCoinDiscountCent,
|
||||||
@@ -398,6 +406,8 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
|||||||
// 可完结时租客实际承担的租金向金额;有 shortfall 时仍展示完整应付便于协商
|
// 可完结时租客实际承担的租金向金额;有 shortfall 时仍展示完整应付便于协商
|
||||||
actualRentAmountCent := actualBuyerRentCent
|
actualRentAmountCent := actualBuyerRentCent
|
||||||
ownerRentIncomeCent := actualOwnerRentCent
|
ownerRentIncomeCent := actualOwnerRentCent
|
||||||
|
insuranceFeeCent := calculateInsuranceFeeCent(ownerRentIncomeCent, order.InsuranceFeeBps)
|
||||||
|
ownerRentIncomeCent = maxCent(ownerRentIncomeCent-insuranceFeeCent, 0)
|
||||||
platformFeeCent := maxCent(actualRentAmountCent-ownerRentIncomeCent, 0)
|
platformFeeCent := maxCent(actualRentAmountCent-ownerRentIncomeCent, 0)
|
||||||
depositCompensationCent := damageAppliedCent
|
depositCompensationCent := damageAppliedCent
|
||||||
|
|
||||||
@@ -409,6 +419,8 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
|||||||
DepositRefundCent: depositRefundCent,
|
DepositRefundCent: depositRefundCent,
|
||||||
RenterRefundCent: rentRefundCent + depositRefundCent,
|
RenterRefundCent: rentRefundCent + depositRefundCent,
|
||||||
PlatformFeeCent: platformFeeCent,
|
PlatformFeeCent: platformFeeCent,
|
||||||
|
InsuranceFeeBps: order.InsuranceFeeBps,
|
||||||
|
InsuranceFeeCent: insuranceFeeCent,
|
||||||
ActualRentAmountCent: actualRentAmountCent,
|
ActualRentAmountCent: actualRentAmountCent,
|
||||||
PureCoinAmountCent: usedBuyerCoinPriceCent,
|
PureCoinAmountCent: usedBuyerCoinPriceCent,
|
||||||
PureCoinDiscountCent: pureCoinDiscountCent,
|
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 {
|
func orderPureCoinOriginalAmountCent(order model.RentalOrder) int64 {
|
||||||
originalRentCent := effectiveRentOriginalAmountCent(order)
|
originalRentCent := effectiveRentOriginalAmountCent(order)
|
||||||
if order.PureCoinOriginalAmountCent > 0 {
|
if order.PureCoinOriginalAmountCent > 0 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,30 +48,45 @@ func (r *Repository) confirmPaid(ctx context.Context, payment *model.PaymentOrde
|
|||||||
var newConvID uint64
|
var newConvID uint64
|
||||||
var notifyFn func(uint64)
|
var notifyFn func(uint64)
|
||||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
if payment.OrderID != 0 {
|
switch payment.BizType {
|
||||||
switch payment.BizType {
|
case qualityOwnerPayBizType:
|
||||||
case "mohong_pay":
|
if r.qualityOwnerRepo == nil {
|
||||||
if r.mohongRepo == nil {
|
return ErrDependencyUnavailable
|
||||||
return ErrDependencyUnavailable
|
}
|
||||||
}
|
if err := r.qualityOwnerRepo.ConfirmQualityOwnerDepositPaidTx(
|
||||||
convID, err := r.mohongRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID)
|
tx,
|
||||||
if err != nil {
|
payment.UserID,
|
||||||
return err
|
payment.AmountCent,
|
||||||
}
|
payment.PaymentNo,
|
||||||
newConvID = convID
|
); err != nil {
|
||||||
notifyFn = r.mohongRepo.NotifyNewConversation
|
return err
|
||||||
default:
|
}
|
||||||
if r.orderRepo == nil {
|
default:
|
||||||
return ErrDependencyUnavailable
|
if payment.OrderID != 0 {
|
||||||
}
|
switch payment.BizType {
|
||||||
convID, err := r.orderRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID)
|
case "mohong_pay":
|
||||||
if err != nil {
|
if r.mohongRepo == nil {
|
||||||
return err
|
return ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
newConvID = convID
|
convID, err := r.mohongRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID)
|
||||||
notifyFn = r.orderRepo.NotifyNewConversation
|
if err != nil {
|
||||||
if err := distribution.SnapshotPaidOrderTx(tx, payment.OrderID, payment.AmountCent); err != nil {
|
return err
|
||||||
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type PaymentDTO struct {
|
|||||||
PaymentNo string `json:"payment_no"`
|
PaymentNo string `json:"payment_no"`
|
||||||
OrderID uint64 `json:"order_id"`
|
OrderID uint64 `json:"order_id"`
|
||||||
OrderNo string `json:"order_no"`
|
OrderNo string `json:"order_no"`
|
||||||
|
BizID uint64 `json:"biz_id"`
|
||||||
PaymentConfigID uint64 `json:"payment_config_id"`
|
PaymentConfigID uint64 `json:"payment_config_id"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
ThirdOrderID string `json:"third_order_id"`
|
ThirdOrderID string `json:"third_order_id"`
|
||||||
@@ -28,6 +29,7 @@ type PaymentDTO struct {
|
|||||||
PayWay string `json:"pay_way"`
|
PayWay string `json:"pay_way"`
|
||||||
JSPayFlag string `json:"jspay_flag"`
|
JSPayFlag string `json:"jspay_flag"`
|
||||||
AmountCent int64 `json:"amount_cent"`
|
AmountCent int64 `json:"amount_cent"`
|
||||||
|
BizType string `json:"biz_type"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
TDCode string `json:"td_code,omitempty"`
|
TDCode string `json:"td_code,omitempty"`
|
||||||
JSPayURL string `json:"jspay_url,omitempty"`
|
JSPayURL string `json:"jspay_url,omitempty"`
|
||||||
@@ -80,6 +82,7 @@ type AdminPaymentDTO struct {
|
|||||||
PaymentNo string `json:"payment_no"`
|
PaymentNo string `json:"payment_no"`
|
||||||
OrderID uint64 `json:"order_id"`
|
OrderID uint64 `json:"order_id"`
|
||||||
OrderNo string `json:"order_no"`
|
OrderNo string `json:"order_no"`
|
||||||
|
BizID uint64 `json:"biz_id"`
|
||||||
UserID uint64 `json:"user_id"`
|
UserID uint64 `json:"user_id"`
|
||||||
PaymentConfigID uint64 `json:"payment_config_id"`
|
PaymentConfigID uint64 `json:"payment_config_id"`
|
||||||
UserPhone string `json:"user_phone"`
|
UserPhone string `json:"user_phone"`
|
||||||
|
|||||||
@@ -111,6 +111,22 @@ func (h *Handler) StartMohong(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
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) {
|
func (h *Handler) Query(c *gin.Context) {
|
||||||
userID, ok := currentUserID(c)
|
userID, ok := currentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -147,6 +163,20 @@ func (h *Handler) QueryMohong(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
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) {
|
func (h *Handler) AdminRefundMohong(c *gin.Context) {
|
||||||
orderID, ok := parseID(c)
|
orderID, ok := parseID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
runtimeConfig, err := r.runtimeConfigForPayment(ctx, payment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrPaymentUnavailable
|
return nil, ErrPaymentUnavailable
|
||||||
@@ -64,8 +69,8 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r
|
|||||||
NotifyURL: runtimeConfig.NotifyURL,
|
NotifyURL: runtimeConfig.NotifyURL,
|
||||||
JumpURL: runtimeConfig.JumpURL,
|
JumpURL: runtimeConfig.JumpURL,
|
||||||
ClientIP: clientIP,
|
ClientIP: clientIP,
|
||||||
Body: "租号订单 " + orderRow.OrderNo,
|
Body: body,
|
||||||
Attach: orderRow.OrderNo,
|
Attach: attach,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error())
|
_ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error())
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func toDTO(payment model.PaymentOrder) PaymentDTO {
|
|||||||
PaymentNo: payment.PaymentNo,
|
PaymentNo: payment.PaymentNo,
|
||||||
OrderID: payment.OrderID,
|
OrderID: payment.OrderID,
|
||||||
OrderNo: payment.OrderNo,
|
OrderNo: payment.OrderNo,
|
||||||
|
BizID: payment.BizID,
|
||||||
PaymentConfigID: payment.PaymentConfigID,
|
PaymentConfigID: payment.PaymentConfigID,
|
||||||
Provider: payment.Provider,
|
Provider: payment.Provider,
|
||||||
ThirdOrderID: payment.ThirdOrderID,
|
ThirdOrderID: payment.ThirdOrderID,
|
||||||
@@ -26,6 +27,7 @@ func toDTO(payment model.PaymentOrder) PaymentDTO {
|
|||||||
PayWay: payment.PayWay,
|
PayWay: payment.PayWay,
|
||||||
JSPayFlag: payment.JSPayFlag,
|
JSPayFlag: payment.JSPayFlag,
|
||||||
AmountCent: payment.AmountCent,
|
AmountCent: payment.AmountCent,
|
||||||
|
BizType: payment.BizType,
|
||||||
Status: payment.Status,
|
Status: payment.Status,
|
||||||
TDCode: payment.TDCode,
|
TDCode: payment.TDCode,
|
||||||
JSPayURL: payment.JSPayURL,
|
JSPayURL: payment.JSPayURL,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,7 @@ func (row adminPaymentRow) toDTO() AdminPaymentDTO {
|
|||||||
PaymentNo: row.PaymentNo,
|
PaymentNo: row.PaymentNo,
|
||||||
OrderID: row.OrderID,
|
OrderID: row.OrderID,
|
||||||
OrderNo: row.OrderNo,
|
OrderNo: row.OrderNo,
|
||||||
|
BizID: row.BizID,
|
||||||
UserID: row.UserID,
|
UserID: row.UserID,
|
||||||
PaymentConfigID: row.PaymentConfigID,
|
PaymentConfigID: row.PaymentConfigID,
|
||||||
UserPhone: row.UserPhone,
|
UserPhone: row.UserPhone,
|
||||||
|
|||||||
@@ -19,12 +19,19 @@ type MohongOrderConfirmer interface {
|
|||||||
NotifyNewConversation(conversationID uint64)
|
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 {
|
type Repository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
configRepo *paymentconfig.Repository
|
configRepo *paymentconfig.Repository
|
||||||
orderRepo *order.Repository
|
orderRepo *order.Repository
|
||||||
mohongRepo MohongOrderConfirmer
|
mohongRepo MohongOrderConfirmer
|
||||||
logger *zap.Logger
|
qualityOwnerRepo QualityOwnerPaymentAdapter
|
||||||
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
type runtimePaymentConfig struct {
|
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 {
|
func (r *Repository) log() *zap.Logger {
|
||||||
if r == nil || r.logger == nil {
|
if r == nil || r.logger == nil {
|
||||||
return zap.NewNop()
|
return zap.NewNop()
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ func (s *Service) StartMohong(ctx context.Context, userID uint64, orderID uint64
|
|||||||
return s.repo.StartMohong(ctx, userID, orderID, req, clientIP)
|
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) {
|
func (s *Service) Query(ctx context.Context, userID uint64, orderID uint64) (*PaymentDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
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)
|
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) {
|
func (s *Service) HandleLeshuaNotify(ctx context.Context, params map[string]string, rawPayload string, contentType string) (*NotifyResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", "优质号主服务暂时不可用")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"hfb_sys/backend/internal/auditlog"
|
"hfb_sys/backend/internal/auditlog"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/qualityowner"
|
||||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||||
smsnode "hfb_sys/backend/internal/modules/sms"
|
smsnode "hfb_sys/backend/internal/modules/sms"
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ var defaultConfigs = []defaultConfig{
|
|||||||
{Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后结账宽限分钟数"},
|
{Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后结账宽限分钟数"},
|
||||||
{Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"},
|
{Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"},
|
||||||
{Key: "listing.publish_cooldown_minutes", Value: "5", Description: "同一用户两次新增发布账号的最小间隔分钟数(0 表示关闭)"},
|
{Key: "listing.publish_cooldown_minutes", Value: "5", Description: "同一用户两次新增发布账号的最小间隔分钟数(0 表示关闭)"},
|
||||||
|
{Key: qualityowner.ConfigKey, Value: qualityowner.DefaultConfigValue(), Description: "优质号主保证金与无忧租号保险费率配置 JSON"},
|
||||||
{Key: listingPublishAgreementsConfigKey, Value: defaultListingPublishAgreementsConfigValue(), Description: "发布账号前协议配置 JSON"},
|
{Key: listingPublishAgreementsConfigKey, Value: defaultListingPublishAgreementsConfigValue(), Description: "发布账号前协议配置 JSON"},
|
||||||
{Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"},
|
{Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"},
|
||||||
{Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), Description: "租后须知配置 JSON"},
|
{Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), Description: "租后须知配置 JSON"},
|
||||||
@@ -91,6 +93,7 @@ var adminVisibleConfigKeys = []string{
|
|||||||
"order.pending_payment_timeout_minutes",
|
"order.pending_payment_timeout_minutes",
|
||||||
"order.return_overdue_grace_minutes",
|
"order.return_overdue_grace_minutes",
|
||||||
"profile.post_rental_notice",
|
"profile.post_rental_notice",
|
||||||
|
qualityowner.ConfigKey,
|
||||||
"renter.growth_level_rules",
|
"renter.growth_level_rules",
|
||||||
"sms.business_templates",
|
"sms.business_templates",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/modules/qualityowner"
|
||||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||||
smsnode "hfb_sys/backend/internal/modules/sms"
|
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
|
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)
|
return s.repo.Update(ctx, actorID, key, req, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/modules/paymentaccount"
|
"hfb_sys/backend/internal/modules/paymentaccount"
|
||||||
"hfb_sys/backend/internal/modules/paymentconfig"
|
"hfb_sys/backend/internal/modules/paymentconfig"
|
||||||
"hfb_sys/backend/internal/modules/pickup"
|
"hfb_sys/backend/internal/modules/pickup"
|
||||||
|
"hfb_sys/backend/internal/modules/qualityowner"
|
||||||
"hfb_sys/backend/internal/modules/realname"
|
"hfb_sys/backend/internal/modules/realname"
|
||||||
"hfb_sys/backend/internal/modules/supportgroup"
|
"hfb_sys/backend/internal/modules/supportgroup"
|
||||||
"hfb_sys/backend/internal/modules/systemconfig"
|
"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 {
|
if deps.DB != nil {
|
||||||
listingRepo = listing.NewRepository(deps.DB, chatCreator)
|
listingRepo = listing.NewRepository(deps.DB, chatCreator)
|
||||||
}
|
}
|
||||||
|
var qualityOwnerRepo *qualityowner.Repository
|
||||||
|
if deps.DB != nil {
|
||||||
|
qualityOwnerRepo = qualityowner.NewRepository(deps.DB)
|
||||||
|
}
|
||||||
var paymentRepo *payment.Repository
|
var paymentRepo *payment.Repository
|
||||||
var orderRepo *order.Repository
|
var orderRepo *order.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
@@ -267,6 +272,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
if mohongRepo != nil {
|
if mohongRepo != nil {
|
||||||
paymentOpts = append(paymentOpts, payment.WithMohongRepo(mohongRepo))
|
paymentOpts = append(paymentOpts, payment.WithMohongRepo(mohongRepo))
|
||||||
}
|
}
|
||||||
|
if qualityOwnerRepo != nil {
|
||||||
|
paymentOpts = append(paymentOpts, payment.WithQualityOwnerRepo(qualityOwnerRepo))
|
||||||
|
}
|
||||||
paymentRepo = payment.NewRepository(deps.DB, paymentConfigRepo, orderRepo, paymentOpts...)
|
paymentRepo = payment.NewRepository(deps.DB, paymentConfigRepo, orderRepo, paymentOpts...)
|
||||||
}
|
}
|
||||||
paymentService := payment.NewService(paymentRepo)
|
paymentService := payment.NewService(paymentRepo)
|
||||||
@@ -309,6 +317,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
}
|
}
|
||||||
systemConfigService := systemconfig.NewService(systemConfigRepo)
|
systemConfigService := systemconfig.NewService(systemConfigRepo)
|
||||||
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
|
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
|
||||||
|
qualityOwnerService := qualityowner.NewService(qualityOwnerRepo)
|
||||||
|
qualityOwnerHandler := qualityowner.NewHandler(qualityOwnerService)
|
||||||
var distributionRepo *distribution.Repository
|
var distributionRepo *distribution.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
distributionRepo = distribution.NewRepository(deps.DB, deps.Redis)
|
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("/me", requireAuth, userHandler.UpdateMe)
|
||||||
api.PUT("/password", requireAuth, authHandler.SetPassword)
|
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 {
|
if invitationHandler != nil {
|
||||||
api.GET("/invitations/code-info", invitationHandler.GetInviteInfo)
|
api.GET("/invitations/code-info", invitationHandler.GetInviteInfo)
|
||||||
api.GET("/invitations/code-info/:code", 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/manual-realname", requirePerm("user:manual_realname"), adminUserHandler.ManualRealname)
|
||||||
adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname)
|
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/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("/orders", requirePerm("order:list"), orderHandler.AdminList)
|
||||||
adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing)
|
adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing)
|
||||||
adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail)
|
adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -7,6 +7,7 @@ export interface AdminPayment {
|
|||||||
payment_no: string
|
payment_no: string
|
||||||
order_id: number
|
order_id: number
|
||||||
order_no: string
|
order_no: string
|
||||||
|
biz_id?: number
|
||||||
user_id: number
|
user_id: number
|
||||||
user_phone: string
|
user_phone: string
|
||||||
provider: string
|
provider: string
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export interface AdminUserItem {
|
|||||||
renter_growth_level_name: string
|
renter_growth_level_name: string
|
||||||
available_balance_cent: number
|
available_balance_cent: number
|
||||||
frozen_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
|
status: UserStatus
|
||||||
order_count: number
|
order_count: number
|
||||||
listing_count: number
|
listing_count: number
|
||||||
@@ -46,6 +50,7 @@ export interface AdminUserQuery {
|
|||||||
keyword?: string
|
keyword?: string
|
||||||
status?: '' | UserStatus
|
status?: '' | UserStatus
|
||||||
has_deposit_free_quota?: boolean
|
has_deposit_free_quota?: boolean
|
||||||
|
quality_owner_only?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminUsers(page = 1, pageSize = 20, query: AdminUserQuery = {}) {
|
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.keyword) params.keyword = query.keyword
|
||||||
if (query.status) params.status = query.status
|
if (query.status) params.status = query.status
|
||||||
if (query.has_deposit_free_quota) params.has_deposit_free_quota = true
|
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<ApiResponse<PaginatedResult<AdminUserItem>>>(
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminUserItem>>>(
|
||||||
'/admin/users',
|
'/admin/users',
|
||||||
{
|
{
|
||||||
@@ -128,3 +134,14 @@ export async function adjustAdminUserWallet(id: number, payload: AdminWalletAdju
|
|||||||
)
|
)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setAdminUserQualityOwner(
|
||||||
|
id: number,
|
||||||
|
payload: { enabled: boolean; reason: string }
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<unknown>>(
|
||||||
|
`/admin/users/${id}/quality-owner`,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -408,6 +408,7 @@ function paymentBizTypeLabel(type: string) {
|
|||||||
rent_refund: '租金退款',
|
rent_refund: '租金退款',
|
||||||
arbitration_refund: '仲裁退款',
|
arbitration_refund: '仲裁退款',
|
||||||
mohong_pay: '撞车支付',
|
mohong_pay: '撞车支付',
|
||||||
|
quality_owner_pay: '优质号主保证金',
|
||||||
mohong_refund: '撞车退款',
|
mohong_refund: '撞车退款',
|
||||||
}
|
}
|
||||||
return labels[type] || type || '-'
|
return labels[type] || type || '-'
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { reactive, watch } from 'vue'
|
||||||
|
|
||||||
|
import { updateSystemConfig, type SystemConfig } from '@/features/admin/api/systemConfigs'
|
||||||
|
import { readError } from '@/shared/utils/error'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
config: SystemConfig
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'saved'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
depositYuan: 200,
|
||||||
|
insurancePercent: 8,
|
||||||
|
premiumInsurancePercent: 4,
|
||||||
|
description: '',
|
||||||
|
saving: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
visible => {
|
||||||
|
if (!visible) return
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(props.config.value || '{}') as Record<string, unknown>
|
||||||
|
form.depositYuan = Number(parsed.deposit_amount_cent || 20000) / 100
|
||||||
|
form.insurancePercent = Number(parsed.insurance_fee_bps || 800) / 100
|
||||||
|
form.premiumInsurancePercent = Number(parsed.premium_insurance_fee_bps || 400) / 100
|
||||||
|
} catch {
|
||||||
|
form.depositYuan = 200
|
||||||
|
form.insurancePercent = 8
|
||||||
|
form.premiumInsurancePercent = 4
|
||||||
|
}
|
||||||
|
form.description = props.config.description || ''
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!(form.depositYuan > 0)) {
|
||||||
|
ElMessage.warning('保证金必须大于 0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
form.insurancePercent < 0 ||
|
||||||
|
form.premiumInsurancePercent < 0 ||
|
||||||
|
form.premiumInsurancePercent > form.insurancePercent
|
||||||
|
) {
|
||||||
|
ElMessage.warning('极品专区费率不能高于普通保险费率')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.saving = true
|
||||||
|
try {
|
||||||
|
await updateSystemConfig(props.config.key, {
|
||||||
|
value: JSON.stringify({
|
||||||
|
deposit_amount_cent: Math.round(form.depositYuan * 100),
|
||||||
|
insurance_fee_bps: Math.round(form.insurancePercent * 100),
|
||||||
|
premium_insurance_fee_bps: Math.round(form.premiumInsurancePercent * 100),
|
||||||
|
}),
|
||||||
|
description: form.description,
|
||||||
|
})
|
||||||
|
ElMessage.success('优质号主与保险费率配置已更新')
|
||||||
|
emit('saved')
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '保存配置失败'))
|
||||||
|
} finally {
|
||||||
|
form.saving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
title="优质号主与无忧租号保险"
|
||||||
|
width="560px"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="优质号主保证金(元)">
|
||||||
|
<el-input-number v-model="form.depositYuan" :min="0.01" :precision="2" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="普通无忧租号保险服务费(%)">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.insurancePercent"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.5"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="优质号主 + 极品专区服务费(%)">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.premiumInsurancePercent"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.5"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="配置说明">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
title="费率按号主实际到手租金计算,并在订单结算时扣减;历史订单保留下单时费率快照。"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="form.saving" @click="save">保存配置</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
@@ -42,6 +42,7 @@ export function paymentBizTypeLabel(type: string) {
|
|||||||
admin_close_refund: '客服关闭退款',
|
admin_close_refund: '客服关闭退款',
|
||||||
admin_seal_refund: '封存退款',
|
admin_seal_refund: '封存退款',
|
||||||
deposit_refund: '暂扣押金归还',
|
deposit_refund: '暂扣押金归还',
|
||||||
|
quality_owner_pay: '优质号主保证金',
|
||||||
}
|
}
|
||||||
return labels[type] || type
|
return labels[type] || type
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -648,6 +648,7 @@ function paymentBizTypeLabel(type: string) {
|
|||||||
admin_refund: '人工退款',
|
admin_refund: '人工退款',
|
||||||
cancel_refund: '取消退款',
|
cancel_refund: '取消退款',
|
||||||
admin_close_refund: '客服关闭退款',
|
admin_close_refund: '客服关闭退款',
|
||||||
|
quality_owner_pay: '优质号主保证金',
|
||||||
}
|
}
|
||||||
return map[type] || type
|
return map[type] || type
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -990,6 +990,20 @@ function formatQuantity(value: number) {
|
|||||||
<strong class="listing-code">{{ formatListingCode(row) }}</strong>
|
<strong class="listing-code">{{ formatListingCode(row) }}</strong>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="专区" width="72">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.is_premium" type="warning" size="small">极品</el-tag>
|
||||||
|
<span v-else>普通</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="赔付方案" width="102">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.compensation_plan === 'insurance'">
|
||||||
|
无忧保险 {{ (Number(row.insurance_fee_bps || 0) / 100).toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
<span v-else>普通赔付</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="地区" width="86">
|
<el-table-column label="地区" width="86">
|
||||||
<template #default="{ row }">{{ regionText(row) }}</template>
|
<template #default="{ row }">{{ regionText(row) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ const filters = reactive({
|
|||||||
|
|
||||||
const payAmount = computed(() =>
|
const payAmount = computed(() =>
|
||||||
payments.value
|
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)
|
.reduce((sum, item) => sum + Number(item.amount_cent || 0), 0)
|
||||||
)
|
)
|
||||||
const refundAmount = computed(() =>
|
const refundAmount = computed(() =>
|
||||||
@@ -123,6 +128,7 @@ function bizTypeLabel(type: string) {
|
|||||||
rent_refund: '租金退款',
|
rent_refund: '租金退款',
|
||||||
arbitration_refund: '仲裁退款',
|
arbitration_refund: '仲裁退款',
|
||||||
mohong_pay: '撞车支付',
|
mohong_pay: '撞车支付',
|
||||||
|
quality_owner_pay: '优质号主保证金',
|
||||||
mohong_refund: '撞车退款',
|
mohong_refund: '撞车退款',
|
||||||
}
|
}
|
||||||
return map[type] || type
|
return map[type] || type
|
||||||
@@ -225,6 +231,7 @@ function jsonText(value: unknown) {
|
|||||||
>
|
>
|
||||||
<el-option label="订单支付" value="order_pay" />
|
<el-option label="订单支付" value="order_pay" />
|
||||||
<el-option label="撞车支付" value="mohong_pay" />
|
<el-option label="撞车支付" value="mohong_pay" />
|
||||||
|
<el-option label="优质号主保证金" value="quality_owner_pay" />
|
||||||
<el-option label="结账退款" value="checkout_refund" />
|
<el-option label="结账退款" value="checkout_refund" />
|
||||||
<el-option label="仲裁退款" value="arbitration_refund" />
|
<el-option label="仲裁退款" value="arbitration_refund" />
|
||||||
<el-option label="人工退款" value="admin_refund" />
|
<el-option label="人工退款" value="admin_refund" />
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import PostRentalNoticeDialog from '../components/PostRentalNoticeDialog.vue'
|
|||||||
import GeneralConfigDialog from '../components/GeneralConfigDialog.vue'
|
import GeneralConfigDialog from '../components/GeneralConfigDialog.vue'
|
||||||
import RenterGrowthConfigDialog from '../components/RenterGrowthConfigDialog.vue'
|
import RenterGrowthConfigDialog from '../components/RenterGrowthConfigDialog.vue'
|
||||||
import SmsNodeTemplatesDialog from '../components/SmsNodeTemplatesDialog.vue'
|
import SmsNodeTemplatesDialog from '../components/SmsNodeTemplatesDialog.vue'
|
||||||
|
import QualityOwnerConfigDialog from '../components/QualityOwnerConfigDialog.vue'
|
||||||
import AutoWelcomeConfig from '../components/AutoWelcomeConfig.vue'
|
import AutoWelcomeConfig from '../components/AutoWelcomeConfig.vue'
|
||||||
import {
|
import {
|
||||||
fetchAutoWelcomeMessage,
|
fetchAutoWelcomeMessage,
|
||||||
@@ -88,6 +89,7 @@ const currentEditingConfig = ref<SystemConfig | null>(null)
|
|||||||
// 各种子 Dialog 的可见性控制
|
// 各种子 Dialog 的可见性控制
|
||||||
const publishVisible = ref(false)
|
const publishVisible = ref(false)
|
||||||
const salePriceVisible = ref(false)
|
const salePriceVisible = ref(false)
|
||||||
|
const qualityOwnerVisible = ref(false)
|
||||||
const announcementsVisible = ref(false)
|
const announcementsVisible = ref(false)
|
||||||
const bannersVisible = ref(false)
|
const bannersVisible = ref(false)
|
||||||
const awRecycleVisible = ref(false)
|
const awRecycleVisible = ref(false)
|
||||||
@@ -105,6 +107,9 @@ const publishConfig = computed(
|
|||||||
const salePriceConfig = computed(
|
const salePriceConfig = computed(
|
||||||
() => configs.value.find(item => item.key === 'listing.sale_price_config') || null
|
() => 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(
|
const listingPublishCooldownConfig = computed(
|
||||||
() => configs.value.find(item => item.key === 'listing.publish_cooldown_minutes') || null
|
() => configs.value.find(item => item.key === 'listing.publish_cooldown_minutes') || null
|
||||||
)
|
)
|
||||||
@@ -176,6 +181,7 @@ const regularConfigs = computed(() =>
|
|||||||
item =>
|
item =>
|
||||||
item.key !== 'listing.publish_options' &&
|
item.key !== 'listing.publish_options' &&
|
||||||
item.key !== 'listing.sale_price_config' &&
|
item.key !== 'listing.sale_price_config' &&
|
||||||
|
item.key !== 'quality_owner.rules' &&
|
||||||
item.key !== 'listing.publish_cooldown_minutes' &&
|
item.key !== 'listing.publish_cooldown_minutes' &&
|
||||||
item.key !== 'mobile.home_announcements' &&
|
item.key !== 'mobile.home_announcements' &&
|
||||||
item.key !== 'mobile.home_banners' &&
|
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 homeStats = computed(() => {
|
||||||
const announcements = parseHomeAnnouncements(homeAnnouncementsConfig.value?.value || '')
|
const announcements = parseHomeAnnouncements(homeAnnouncementsConfig.value?.value || '')
|
||||||
const banners = parseHomeBanners(homeBannersConfig.value?.value || '')
|
const banners = parseHomeBanners(homeBannersConfig.value?.value || '')
|
||||||
@@ -343,6 +362,8 @@ function openEdit(row: SystemConfig) {
|
|||||||
publishVisible.value = true
|
publishVisible.value = true
|
||||||
} else if (row.key === 'listing.sale_price_config') {
|
} else if (row.key === 'listing.sale_price_config') {
|
||||||
salePriceVisible.value = true
|
salePriceVisible.value = true
|
||||||
|
} else if (row.key === 'quality_owner.rules') {
|
||||||
|
qualityOwnerVisible.value = true
|
||||||
} else if (row.key === 'mobile.home_announcements') {
|
} else if (row.key === 'mobile.home_announcements') {
|
||||||
announcementsVisible.value = true
|
announcementsVisible.value = true
|
||||||
} else if (row.key === 'mobile.home_banners') {
|
} else if (row.key === 'mobile.home_banners') {
|
||||||
@@ -633,6 +654,43 @@ function formatConfigValue(row: SystemConfig) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="qualityOwnerConfig" class="publish-config-panel">
|
||||||
|
<div class="publish-config-main">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Premium Owner</p>
|
||||||
|
<h2>优质号主与无忧租号保险</h2>
|
||||||
|
<span>
|
||||||
|
配置优质号主保证金,以及普通发布和「优质号主 + 极品专区」的无忧租号保险服务费率。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="openEdit(qualityOwnerConfig)">
|
||||||
|
编辑保证金与费率
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="publish-stat-grid home-stat-grid">
|
||||||
|
<div class="publish-stat">
|
||||||
|
<strong>¥{{ qualityOwnerStats.deposit }}</strong>
|
||||||
|
<span>优质号主保证金</span>
|
||||||
|
</div>
|
||||||
|
<div class="publish-stat">
|
||||||
|
<strong>{{ qualityOwnerStats.insurance }}%</strong>
|
||||||
|
<span>普通保险费率</span>
|
||||||
|
</div>
|
||||||
|
<div class="publish-stat">
|
||||||
|
<strong>{{ qualityOwnerStats.premium }}%</strong>
|
||||||
|
<span>极品专区专享费率</span>
|
||||||
|
</div>
|
||||||
|
<div class="publish-stat">
|
||||||
|
<strong>订单快照</strong>
|
||||||
|
<span>历史订单不受调价影响</span>
|
||||||
|
</div>
|
||||||
|
<div class="publish-stat">
|
||||||
|
<strong>更新</strong>
|
||||||
|
<span>{{ formatHomeConfigStatus(qualityOwnerConfig, '未初始化') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-if="renterGrowthConfig" class="publish-config-panel">
|
<section v-if="renterGrowthConfig" class="publish-config-panel">
|
||||||
<div class="publish-config-main">
|
<div class="publish-config-main">
|
||||||
<div>
|
<div>
|
||||||
@@ -1009,6 +1067,13 @@ function formatConfigValue(row: SystemConfig) {
|
|||||||
@saved="loadConfigs"
|
@saved="loadConfigs"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<QualityOwnerConfigDialog
|
||||||
|
v-if="qualityOwnerConfig"
|
||||||
|
v-model="qualityOwnerVisible"
|
||||||
|
:config="qualityOwnerConfig"
|
||||||
|
@saved="loadConfigs"
|
||||||
|
/>
|
||||||
|
|
||||||
<HomeAnnouncementsDialog
|
<HomeAnnouncementsDialog
|
||||||
v-if="homeAnnouncementsConfig"
|
v-if="homeAnnouncementsConfig"
|
||||||
v-model="announcementsVisible"
|
v-model="announcementsVisible"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
manualAdminUserRealname,
|
manualAdminUserRealname,
|
||||||
revokeAdminUserRealname,
|
revokeAdminUserRealname,
|
||||||
setAdminUserDepositFreeQuota,
|
setAdminUserDepositFreeQuota,
|
||||||
|
setAdminUserQualityOwner,
|
||||||
unfreezeAdminUser,
|
unfreezeAdminUser,
|
||||||
type AdminUserItem,
|
type AdminUserItem,
|
||||||
type AdminUserQuery,
|
type AdminUserQuery,
|
||||||
@@ -27,6 +28,7 @@ import AdminUserDetailDrawer from '../components/AdminUserDetailDrawer.vue'
|
|||||||
const adminSession = useAdminSessionStore()
|
const adminSession = useAdminSessionStore()
|
||||||
const canAdjustWallet = computed(() => adminSession.hasPermission('user:wallet_adjust'))
|
const canAdjustWallet = computed(() => adminSession.hasPermission('user:wallet_adjust'))
|
||||||
const canAdjustGrowthPoints = computed(() => adminSession.hasPermission('user:growth_points'))
|
const canAdjustGrowthPoints = computed(() => adminSession.hasPermission('user:growth_points'))
|
||||||
|
const canSetQualityOwner = computed(() => adminSession.hasPermission('user:quality_owner'))
|
||||||
const canViewUserDetail = computed(
|
const canViewUserDetail = computed(
|
||||||
() =>
|
() =>
|
||||||
adminSession.hasPermission('wallet:view') ||
|
adminSession.hasPermission('wallet:view') ||
|
||||||
@@ -65,6 +67,7 @@ const filters = reactive<AdminUserQuery>({
|
|||||||
keyword: '',
|
keyword: '',
|
||||||
status: '',
|
status: '',
|
||||||
has_deposit_free_quota: false,
|
has_deposit_free_quota: false,
|
||||||
|
quality_owner_only: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -87,6 +90,7 @@ function resetFilters() {
|
|||||||
filters.keyword = ''
|
filters.keyword = ''
|
||||||
filters.status = ''
|
filters.status = ''
|
||||||
filters.has_deposit_free_quota = false
|
filters.has_deposit_free_quota = false
|
||||||
|
filters.quality_owner_only = false
|
||||||
queryUsers()
|
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) {
|
function moneyCent(value: number | string | undefined) {
|
||||||
return formatCent(Number(value || 0))
|
return formatCent(Number(value || 0))
|
||||||
}
|
}
|
||||||
@@ -363,6 +401,11 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
仅看有额度
|
仅看有额度
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="号主类型">
|
||||||
|
<el-checkbox v-model="filters.quality_owner_only" @change="queryUsers">
|
||||||
|
仅看优质号主
|
||||||
|
</el-checkbox>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :icon="Search" :loading="loading" @click="queryUsers"
|
<el-button type="primary" :icon="Search" :loading="loading" @click="queryUsers"
|
||||||
>查询</el-button
|
>查询</el-button
|
||||||
@@ -382,6 +425,14 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
<el-table-column label="实名状态" width="100">
|
<el-table-column label="实名状态" width="100">
|
||||||
<template #default="{ row }">{{ realnameStatusLabel(row.realname_status) }}</template>
|
<template #default="{ row }">{{ realnameStatusLabel(row.realname_status) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="号主类型" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.quality_owner_status === 'active'" type="warning" effect="light">
|
||||||
|
优质号主
|
||||||
|
</el-tag>
|
||||||
|
<span v-else class="muted-text">普通</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="成长等级" width="100">
|
<el-table-column label="成长等级" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag type="success" effect="light">{{
|
<el-tag type="success" effect="light">{{
|
||||||
@@ -424,7 +475,7 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
<el-table-column label="注册时间" min-width="160">
|
<el-table-column label="注册时间" min-width="160">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="400" fixed="right" align="right">
|
<el-table-column label="操作" width="470" fixed="right" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button
|
<el-button
|
||||||
@@ -457,6 +508,16 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
<el-button text type="primary" size="small" @click="openDepositQuota(row)">
|
<el-button text type="primary" size="small" @click="openDepositQuota(row)">
|
||||||
人工免押
|
人工免押
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="canSetQualityOwner"
|
||||||
|
text
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="handleToggleQualityOwner(row)"
|
||||||
|
>
|
||||||
|
{{ row.quality_owner_status === 'active' ? '取消优质' : '设为优质' }}
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="row.realname_status !== 'verified'"
|
v-if="row.realname_status !== 'verified'"
|
||||||
text
|
text
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface AuthUser {
|
|||||||
credit_score: number
|
credit_score: number
|
||||||
renter_growth_points?: number
|
renter_growth_points?: number
|
||||||
renter_growth_level?: string
|
renter_growth_level?: string
|
||||||
|
quality_owner_status?: string
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,16 @@
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.owner-quality-tag {
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 1px solid #d8b4fe;
|
||||||
|
background: linear-gradient(135deg, #6d28d9 0%, #9333ea 100%) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
font-size: 10px !important;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.user-meta {
|
.user-meta {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
@@ -664,6 +674,76 @@
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quality-owner-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 10px 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #e9e2fb;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, #faf8ff 0%, #ffffff 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-card.is-active {
|
||||||
|
border-color: #c4b5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-heading,
|
||||||
|
.quality-owner-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-heading > div,
|
||||||
|
.quality-owner-info > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-heading span,
|
||||||
|
.quality-owner-info span {
|
||||||
|
color: #7b8492;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-heading strong {
|
||||||
|
color: #312e81;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-info {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-info > div {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(245, 243, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-info strong {
|
||||||
|
color: #6d28d9;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: #7b8492;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* ========== 响应式适配 ========== */
|
/* ========== 响应式适配 ========== */
|
||||||
@media (min-width: 520px) {
|
@media (min-width: 520px) {
|
||||||
.mobile-profile-shell {
|
.mobile-profile-shell {
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ import { uploadFile } from '@/shared/api/files'
|
|||||||
import { formatCent } from '@/shared/utils/money'
|
import { formatCent } from '@/shared/utils/money'
|
||||||
import { setPassword } from '@/features/auth/api/auth'
|
import { setPassword } from '@/features/auth/api/auth'
|
||||||
import { fetchUserInvitationSummary } from '@/features/invitation/api/invitation'
|
import { fetchUserInvitationSummary } from '@/features/invitation/api/invitation'
|
||||||
|
import {
|
||||||
|
activateQualityOwner,
|
||||||
|
fetchQualityOwnerStatus,
|
||||||
|
queryQualityOwnerPayment,
|
||||||
|
startQualityOwnerPayment,
|
||||||
|
type QualityOwnerStatus,
|
||||||
|
} from '@/features/quality-owner/api/qualityOwner'
|
||||||
|
import type { PaymentPayWay } from '@/features/orders/api/orders'
|
||||||
|
import { useMobilePayWaySelect } from '@/features/orders/composables/useMobilePayWaySelect'
|
||||||
|
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
|
||||||
|
import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySelectPopup.vue'
|
||||||
|
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
|
||||||
|
|
||||||
import { useDistributionSiteStore } from '@/stores/distributionSite'
|
import { useDistributionSiteStore } from '@/stores/distributionSite'
|
||||||
|
|
||||||
@@ -33,6 +45,22 @@ const { unreadCount: notificationUnreadCount, unreadLabel: notificationUnreadLab
|
|||||||
|
|
||||||
/** 数据项 */
|
/** 数据项 */
|
||||||
const availableBalanceCent = ref(0)
|
const availableBalanceCent = ref(0)
|
||||||
|
const qualityOwnerStatus = ref<QualityOwnerStatus | null>(null)
|
||||||
|
const qualityOwnerLoading = ref(false)
|
||||||
|
const qualityOwnerOnlineLoading = ref(false)
|
||||||
|
const qualityOwnerPayWaySelect = useMobilePayWaySelect()
|
||||||
|
const qualityOwnerCashier = useMobilePaymentCashier({
|
||||||
|
queryPayment: () => queryQualityOwnerPayment(),
|
||||||
|
paidMessage: '保证金支付成功,优质号主已开通',
|
||||||
|
})
|
||||||
|
const {
|
||||||
|
paymentPopupVisible: qualityOwnerPaymentPopupVisible,
|
||||||
|
activePayment: activeQualityOwnerPayment,
|
||||||
|
payURL: qualityOwnerPaymentPayURL,
|
||||||
|
paymentQRCodeURL: qualityOwnerPaymentQRCodeURL,
|
||||||
|
qrGenerating: qualityOwnerPaymentQRGenerating,
|
||||||
|
checkingPayment: qualityOwnerPaymentChecking,
|
||||||
|
} = qualityOwnerCashier
|
||||||
const showSettings = ref(false)
|
const showSettings = ref(false)
|
||||||
const showProfileEditor = ref(false)
|
const showProfileEditor = ref(false)
|
||||||
const savingProfile = ref(false)
|
const savingProfile = ref(false)
|
||||||
@@ -121,10 +149,72 @@ onMounted(async () => {
|
|||||||
// 保留本地会话展示,避免个人中心因网络短暂异常整页不可用。
|
// 保留本地会话展示,避免个人中心因网络短暂异常整页不可用。
|
||||||
}
|
}
|
||||||
void loadBalance()
|
void loadBalance()
|
||||||
|
void loadQualityOwnerStatus()
|
||||||
void loadInviteRate()
|
void loadInviteRate()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadQualityOwnerStatus() {
|
||||||
|
try {
|
||||||
|
qualityOwnerStatus.value = await fetchQualityOwnerStatus()
|
||||||
|
} catch {
|
||||||
|
qualityOwnerStatus.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleActivateQualityOwner() {
|
||||||
|
const status = qualityOwnerStatus.value
|
||||||
|
if (!status || status.active) return
|
||||||
|
try {
|
||||||
|
await showDialog({
|
||||||
|
title: '开通优质号主',
|
||||||
|
message: `确认从可用余额中扣除 ¥${formatCent(
|
||||||
|
status.required_deposit_cent
|
||||||
|
)} 作为保证金?取消资格时会退回余额。`,
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: '确认缴纳',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qualityOwnerLoading.value = true
|
||||||
|
try {
|
||||||
|
qualityOwnerStatus.value = await activateQualityOwner()
|
||||||
|
await session.loadMe()
|
||||||
|
await loadBalance()
|
||||||
|
showToast({ message: '优质号主已开通', icon: 'passed' })
|
||||||
|
} catch (error) {
|
||||||
|
showToast({ message: readError(error, '开通失败,请确认余额充足'), icon: 'cross' })
|
||||||
|
} finally {
|
||||||
|
qualityOwnerLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOnlineQualityOwnerPayment() {
|
||||||
|
const payWay = await qualityOwnerPayWaySelect.select()
|
||||||
|
if (!payWay) return
|
||||||
|
qualityOwnerOnlineLoading.value = true
|
||||||
|
try {
|
||||||
|
const payment = await startQualityOwnerPayment(payWay as PaymentPayWay)
|
||||||
|
if (payment.paid) {
|
||||||
|
await handleQualityOwnerPaid()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await qualityOwnerCashier.openMobilePaymentCashier(payment, handleQualityOwnerPaid)
|
||||||
|
} catch (error) {
|
||||||
|
showToast({ message: readError(error, '发起在线支付失败'), icon: 'cross' })
|
||||||
|
} finally {
|
||||||
|
qualityOwnerOnlineLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleQualityOwnerPaid() {
|
||||||
|
qualityOwnerStatus.value = await fetchQualityOwnerStatus()
|
||||||
|
await session.loadMe()
|
||||||
|
await loadBalance()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadBalance() {
|
async function loadBalance() {
|
||||||
try {
|
try {
|
||||||
const wallet = await fetchWalletBalance()
|
const wallet = await fetchWalletBalance()
|
||||||
@@ -366,6 +456,9 @@ function resolveAvatarURL(url: string | undefined | null) {
|
|||||||
>
|
>
|
||||||
未实名
|
未实名
|
||||||
</van-tag>
|
</van-tag>
|
||||||
|
<van-tag v-if="qualityOwnerStatus?.active" round class="owner-quality-tag">
|
||||||
|
<van-icon name="award-o" /> 优质号主
|
||||||
|
</van-tag>
|
||||||
</div>
|
</div>
|
||||||
<p class="user-meta">ID: {{ displayId }} · {{ maskedPhone }}</p>
|
<p class="user-meta">ID: {{ displayId }} · {{ maskedPhone }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,6 +486,65 @@ function resolveAvatarURL(url: string | undefined | null) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="quality-owner-card" :class="{ 'is-active': qualityOwnerStatus?.active }">
|
||||||
|
<div class="quality-owner-heading">
|
||||||
|
<div>
|
||||||
|
<span>优质号主</span>
|
||||||
|
<strong>{{ qualityOwnerStatus?.active ? '已开通' : '解锁极品专区发布权限' }}</strong>
|
||||||
|
</div>
|
||||||
|
<van-tag :type="qualityOwnerStatus?.active ? 'success' : 'default'" round>
|
||||||
|
{{ qualityOwnerStatus?.active ? '优质号主' : '未开通' }}
|
||||||
|
</van-tag>
|
||||||
|
</div>
|
||||||
|
<div class="quality-owner-info">
|
||||||
|
<div>
|
||||||
|
<span>保证金</span>
|
||||||
|
<strong>¥{{ formatCent(qualityOwnerStatus?.required_deposit_cent || 20000) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>极品专区保险费率</span>
|
||||||
|
<strong
|
||||||
|
>{{
|
||||||
|
((qualityOwnerStatus?.premium_insurance_fee_bps || 400) / 100).toFixed(1)
|
||||||
|
}}%</strong
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!qualityOwnerStatus?.active" class="quality-owner-actions">
|
||||||
|
<van-button
|
||||||
|
block
|
||||||
|
round
|
||||||
|
type="primary"
|
||||||
|
color="#7c3aed"
|
||||||
|
:loading="qualityOwnerLoading"
|
||||||
|
:disabled="
|
||||||
|
(qualityOwnerStatus?.available_balance_cent || 0) <
|
||||||
|
(qualityOwnerStatus?.required_deposit_cent || 20000)
|
||||||
|
"
|
||||||
|
@click="handleActivateQualityOwner"
|
||||||
|
>
|
||||||
|
余额支付开通
|
||||||
|
</van-button>
|
||||||
|
<van-button
|
||||||
|
block
|
||||||
|
round
|
||||||
|
plain
|
||||||
|
type="primary"
|
||||||
|
:loading="qualityOwnerOnlineLoading"
|
||||||
|
@click="handleOnlineQualityOwnerPayment"
|
||||||
|
>
|
||||||
|
微信 / 支付宝支付开通
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
<p v-else>
|
||||||
|
{{
|
||||||
|
qualityOwnerStatus?.deposit_source === 'manual'
|
||||||
|
? '已由后台开通优质号主。'
|
||||||
|
: '保证金已冻结,取消优质号主资格时退回可用余额。'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 余额卡片 - 银行卡质感暗色卡片 -->
|
<!-- 余额卡片 - 银行卡质感暗色卡片 -->
|
||||||
<div class="balance-card">
|
<div class="balance-card">
|
||||||
<div class="balance-row">
|
<div class="balance-row">
|
||||||
@@ -496,6 +648,22 @@ function resolveAvatarURL(url: string | undefined | null) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<MobilePayWaySelectPopup
|
||||||
|
v-model:show="qualityOwnerPayWaySelect.visible.value"
|
||||||
|
@choose="qualityOwnerPayWaySelect.choose"
|
||||||
|
@closed="qualityOwnerPayWaySelect.handleClosed"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MobilePaymentCashierPopup
|
||||||
|
v-model:show="qualityOwnerPaymentPopupVisible"
|
||||||
|
:payment="activeQualityOwnerPayment"
|
||||||
|
:pay-url="qualityOwnerPaymentPayURL"
|
||||||
|
:qr-code-url="qualityOwnerPaymentQRCodeURL"
|
||||||
|
:qr-generating="qualityOwnerPaymentQRGenerating"
|
||||||
|
:checking="qualityOwnerPaymentChecking"
|
||||||
|
@refresh="qualityOwnerCashier.refreshPaymentStatus(false)"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 账单明细 Popup -->
|
<!-- 账单明细 Popup -->
|
||||||
<van-popup
|
<van-popup
|
||||||
v-model:show="showLedgers"
|
v-model:show="showLedgers"
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import {
|
|||||||
Camera,
|
Camera,
|
||||||
CircleCheckFilled,
|
CircleCheckFilled,
|
||||||
EditPen,
|
EditPen,
|
||||||
|
Loading,
|
||||||
Lock,
|
Lock,
|
||||||
|
Picture,
|
||||||
Postcard,
|
Postcard,
|
||||||
RefreshRight,
|
RefreshRight,
|
||||||
|
Trophy,
|
||||||
User,
|
User,
|
||||||
WarningFilled,
|
WarningFilled,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -18,10 +21,25 @@ import { uploadFile } from '@/shared/api/files'
|
|||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { realnameStatusLabel } from '@/shared/utils/statusLabels'
|
import { realnameStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
import { setPassword } from '@/features/auth/api/auth'
|
import { setPassword } from '@/features/auth/api/auth'
|
||||||
|
import {
|
||||||
|
activateQualityOwner,
|
||||||
|
fetchQualityOwnerStatus,
|
||||||
|
queryQualityOwnerPayment,
|
||||||
|
startQualityOwnerPayment,
|
||||||
|
type QualityOwnerStatus,
|
||||||
|
} from '@/features/quality-owner/api/qualityOwner'
|
||||||
|
import { formatCent } from '@/shared/utils/money'
|
||||||
|
import type { PaymentPayWay } from '@/features/orders/api/orders'
|
||||||
|
import { useOrderPaymentCashier } from '@/features/orders/composables/useOrderPaymentCashier'
|
||||||
|
import PayWaySelectDialog from '@/features/orders/components/PayWaySelectDialog.vue'
|
||||||
|
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const qualityOwnerLoading = ref(false)
|
||||||
|
const qualityOwnerOnlineLoading = ref(false)
|
||||||
|
const qualityOwnerPayWayVisible = ref(false)
|
||||||
|
const qualityOwnerStatus = ref<QualityOwnerStatus | null>(null)
|
||||||
const uploadingAvatar = ref(false)
|
const uploadingAvatar = ref(false)
|
||||||
const avatarFileInput = ref<HTMLInputElement | null>(null)
|
const avatarFileInput = ref<HTMLInputElement | null>(null)
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -36,6 +54,20 @@ const passwordForm = reactive({
|
|||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const qualityOwnerCashier = useOrderPaymentCashier({
|
||||||
|
notifySuccess: message => ElMessage.success(message),
|
||||||
|
notifyError: message => ElMessage.error(message),
|
||||||
|
notifyInfo: message => ElMessage.info(message),
|
||||||
|
notifyFallback: message => ElMessage.warning(message),
|
||||||
|
paidMessage: '保证金支付成功,优质号主已开通',
|
||||||
|
queryPayment: () => queryQualityOwnerPayment(),
|
||||||
|
})
|
||||||
|
const qualityOwnerPaymentVisible = qualityOwnerCashier.paymentPopupVisible
|
||||||
|
const activeQualityOwnerPayment = qualityOwnerCashier.activePayment
|
||||||
|
const qualityOwnerPaymentQRCodeURL = qualityOwnerCashier.paymentQRCodeURL
|
||||||
|
const qualityOwnerQrGenerating = qualityOwnerCashier.qrGenerating
|
||||||
|
const qualityOwnerCheckingPayment = qualityOwnerCashier.checkingPayment
|
||||||
|
|
||||||
const displayName = computed(() => session.displayName)
|
const displayName = computed(() => session.displayName)
|
||||||
const maskedPhone = computed(() => {
|
const maskedPhone = computed(() => {
|
||||||
if (!session.phone) return '未绑定手机号'
|
if (!session.phone) return '未绑定手机号'
|
||||||
@@ -56,8 +88,72 @@ onMounted(async () => {
|
|||||||
// 保留本地会话展示,避免资料页因网络短暂异常整页不可用。
|
// 保留本地会话展示,避免资料页因网络短暂异常整页不可用。
|
||||||
}
|
}
|
||||||
resetForm()
|
resetForm()
|
||||||
|
void loadQualityOwnerStatus()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadQualityOwnerStatus() {
|
||||||
|
try {
|
||||||
|
qualityOwnerStatus.value = await fetchQualityOwnerStatus()
|
||||||
|
} catch {
|
||||||
|
qualityOwnerStatus.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleActivateQualityOwner() {
|
||||||
|
const status = qualityOwnerStatus.value
|
||||||
|
if (!status || status.active) return
|
||||||
|
const deposit = formatCent(status.required_deposit_cent)
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认从可用余额中扣除 ¥${deposit} 作为优质号主保证金?保证金会冻结,取消优质号主资格时退回。`,
|
||||||
|
'开通优质号主',
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确认缴纳',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qualityOwnerLoading.value = true
|
||||||
|
try {
|
||||||
|
qualityOwnerStatus.value = await activateQualityOwner()
|
||||||
|
await session.loadMe()
|
||||||
|
ElMessage.success('优质号主已开通,可在发布时选择极品专区')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '开通失败,请确认余额充足'))
|
||||||
|
} finally {
|
||||||
|
qualityOwnerLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openQualityOwnerOnlinePayment() {
|
||||||
|
qualityOwnerPayWayVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseQualityOwnerPayWay(payWay: PaymentPayWay) {
|
||||||
|
qualityOwnerPayWayVisible.value = false
|
||||||
|
qualityOwnerOnlineLoading.value = true
|
||||||
|
try {
|
||||||
|
const payment = await startQualityOwnerPayment(payWay)
|
||||||
|
if (payment.paid) {
|
||||||
|
await handleQualityOwnerPaymentSucceeded()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await qualityOwnerCashier.openPaymentCashier(payment, handleQualityOwnerPaymentSucceeded)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '发起在线支付失败'))
|
||||||
|
} finally {
|
||||||
|
qualityOwnerOnlineLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleQualityOwnerPaymentSucceeded() {
|
||||||
|
qualityOwnerStatus.value = await fetchQualityOwnerStatus()
|
||||||
|
await session.loadMe()
|
||||||
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
form.nickname = session.nickname || session.displayName
|
form.nickname = session.nickname || session.displayName
|
||||||
form.avatar_url = session.avatarUrl || ''
|
form.avatar_url = session.avatarUrl || ''
|
||||||
@@ -153,14 +249,20 @@ async function savePassword() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="profile-page">
|
<section class="profile-page">
|
||||||
<div class="profile-hero">
|
<div class="profile-hero" :class="{ 'is-quality-owner': qualityOwnerStatus?.active }">
|
||||||
<div class="hero-avatar">
|
<div class="hero-avatar">
|
||||||
<img v-if="session.avatarUrl" :src="resolveAvatarURL(session.avatarUrl)" alt="" />
|
<img v-if="session.avatarUrl" :src="resolveAvatarURL(session.avatarUrl)" alt="" />
|
||||||
<span v-else>{{ session.avatarText }}</span>
|
<span v-else>{{ session.avatarText }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<span class="eyebrow">Profile</span>
|
<span class="eyebrow">Profile</span>
|
||||||
<h1>个人资料</h1>
|
<div class="hero-title-row">
|
||||||
|
<h1>个人资料</h1>
|
||||||
|
<span v-if="qualityOwnerStatus?.active" class="hero-quality-chip">
|
||||||
|
<el-icon><Trophy /></el-icon>
|
||||||
|
优质号主
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p>{{ displayName }} · {{ maskedPhone }}</p>
|
<p>{{ displayName }} · {{ maskedPhone }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -176,6 +278,24 @@ async function savePassword() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section v-if="qualityOwnerStatus?.active" class="quality-owner-spotlight">
|
||||||
|
<div class="quality-owner-spotlight-mark">
|
||||||
|
<el-icon :size="25"><Trophy /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="quality-owner-spotlight-copy">
|
||||||
|
<span>PREMIUM OWNER</span>
|
||||||
|
<strong>优质号主身份已点亮</strong>
|
||||||
|
<p>
|
||||||
|
你的账号将优先展示优质号主标识,并可发布至首页极品专区,享受更高曝光与 4% 保险专享费率。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="quality-owner-spotlight-benefits">
|
||||||
|
<span>极致曝光</span>
|
||||||
|
<span>身份认证</span>
|
||||||
|
<span>专享费率</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="profile-layout">
|
<div class="profile-layout">
|
||||||
<section class="edit-panel">
|
<section class="edit-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
@@ -280,6 +400,104 @@ async function savePassword() {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="quality-owner-card" :class="{ 'is-active': qualityOwnerStatus?.active }">
|
||||||
|
<div class="quality-owner-heading">
|
||||||
|
<div>
|
||||||
|
<span class="card-kicker">Premium Owner</span>
|
||||||
|
<h2>优质号主</h2>
|
||||||
|
<p>
|
||||||
|
开通后可发布到极品专区;在极品专区购买无忧租号保险可享
|
||||||
|
{{ ((qualityOwnerStatus?.premium_insurance_fee_bps || 400) / 100).toFixed(1) }}%
|
||||||
|
专享费率。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="quality-owner-status">
|
||||||
|
{{ qualityOwnerStatus?.active ? '已开通' : '未开通' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="quality-owner-metrics">
|
||||||
|
<div>
|
||||||
|
<span>保证金</span>
|
||||||
|
<strong>¥{{ formatCent(qualityOwnerStatus?.required_deposit_cent || 20000) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>普通保险服务费</span>
|
||||||
|
<strong>{{ ((qualityOwnerStatus?.insurance_fee_bps || 800) / 100).toFixed(1) }}%</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>极品专区保险服务费</span>
|
||||||
|
<strong
|
||||||
|
>{{
|
||||||
|
((qualityOwnerStatus?.premium_insurance_fee_bps || 400) / 100).toFixed(1)
|
||||||
|
}}%</strong
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>当前可用余额</span>
|
||||||
|
<strong>¥{{ formatCent(qualityOwnerStatus?.available_balance_cent || 0) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="quality-owner-actions">
|
||||||
|
<span v-if="qualityOwnerStatus?.active">
|
||||||
|
<template v-if="qualityOwnerStatus.deposit_source === 'manual'">已由后台开通</template>
|
||||||
|
<template v-else>保证金已冻结</template>
|
||||||
|
</span>
|
||||||
|
<span v-else>保证金取消资格时原路退回余额。</span>
|
||||||
|
<div v-if="!qualityOwnerStatus?.active" class="quality-owner-action-buttons">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="qualityOwnerLoading"
|
||||||
|
:disabled="
|
||||||
|
(qualityOwnerStatus?.available_balance_cent || 0) <
|
||||||
|
(qualityOwnerStatus?.required_deposit_cent || 20000)
|
||||||
|
"
|
||||||
|
@click="handleActivateQualityOwner"
|
||||||
|
>
|
||||||
|
余额支付开通
|
||||||
|
</el-button>
|
||||||
|
<el-button :loading="qualityOwnerOnlineLoading" @click="openQualityOwnerOnlinePayment">
|
||||||
|
微信 / 支付宝支付开通
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PayWaySelectDialog v-model="qualityOwnerPayWayVisible" @choose="chooseQualityOwnerPayWay" />
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="qualityOwnerPaymentVisible"
|
||||||
|
title="优质号主保证金支付"
|
||||||
|
width="460px"
|
||||||
|
append-to-body
|
||||||
|
class="quality-owner-pay-dialog"
|
||||||
|
>
|
||||||
|
<div v-if="activeQualityOwnerPayment" class="quality-owner-pay-dialog-body">
|
||||||
|
<div class="quality-owner-pay-amount">
|
||||||
|
<span>支付金额</span>
|
||||||
|
<strong>¥{{ formatCent(activeQualityOwnerPayment.amount_cent) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="quality-owner-pay-qr">
|
||||||
|
<el-icon v-if="qualityOwnerQrGenerating" class="is-loading" :size="30">
|
||||||
|
<Loading />
|
||||||
|
</el-icon>
|
||||||
|
<img
|
||||||
|
v-else-if="qualityOwnerPaymentQRCodeURL"
|
||||||
|
:src="qualityOwnerPaymentQRCodeURL"
|
||||||
|
alt="保证金支付二维码"
|
||||||
|
/>
|
||||||
|
<el-icon v-else :size="42"><Picture /></el-icon>
|
||||||
|
</div>
|
||||||
|
<p>请使用{{ activeQualityOwnerPayment.pay_way === 'WXZF' ? '微信' : '支付宝' }}扫码支付</p>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="qualityOwnerCheckingPayment"
|
||||||
|
@click="qualityOwnerCashier.refreshPaymentStatus(false)"
|
||||||
|
>
|
||||||
|
我已支付,刷新状态
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="showPasswordDialog"
|
v-model="showPasswordDialog"
|
||||||
title="修改密码"
|
title="修改密码"
|
||||||
@@ -345,6 +563,19 @@ async function savePassword() {
|
|||||||
box-shadow: 0 16px 36px rgba(23, 35, 61, 0.08);
|
box-shadow: 0 16px 36px rgba(23, 35, 61, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-hero.is-quality-owner {
|
||||||
|
border-color: #d8c9fb;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 86% 0%, rgba(245, 158, 11, 0.15), transparent 34%),
|
||||||
|
linear-gradient(135deg, #f3edff 0%, #ffffff 58%, #fff8e8 100%);
|
||||||
|
box-shadow: 0 16px 42px rgba(109, 40, 217, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-hero.is-quality-owner .hero-avatar {
|
||||||
|
background: linear-gradient(135deg, #5b21b6 0%, #9333ea 62%, #f59e0b 100%);
|
||||||
|
box-shadow: 0 8px 20px rgba(109, 40, 217, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
.hero-avatar,
|
.hero-avatar,
|
||||||
.avatar-preview {
|
.avatar-preview {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -389,6 +620,27 @@ async function savePassword() {
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-quality-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #d8b4fe;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #6d28d9;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-copy p {
|
.hero-copy p {
|
||||||
margin: 10px 0 0;
|
margin: 10px 0 0;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
@@ -433,6 +685,69 @@ async function savePassword() {
|
|||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quality-owner-spotlight {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 17px 20px;
|
||||||
|
border: 1px solid #e1d7fa;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(110deg, #faf7ff 0%, #ffffff 62%, #fffaf0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-spotlight-mark {
|
||||||
|
display: grid;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #5b21b6 0%, #9333ea 65%, #f59e0b 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-spotlight-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-spotlight-copy > span {
|
||||||
|
color: #7c3aed;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-spotlight-copy strong {
|
||||||
|
color: #2e1065;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-spotlight-copy p {
|
||||||
|
margin: 0;
|
||||||
|
color: #70677f;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-spotlight-benefits {
|
||||||
|
display: flex;
|
||||||
|
gap: 7px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-spotlight-benefits span {
|
||||||
|
padding: 5px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #6d28d9;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-layout {
|
.profile-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 360px;
|
grid-template-columns: minmax(0, 1fr) 360px;
|
||||||
@@ -647,9 +962,178 @@ async function savePassword() {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quality-owner-card {
|
||||||
|
margin-top: 22px;
|
||||||
|
padding: 26px 28px;
|
||||||
|
border: 1px solid #e8e4f4;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, #faf8ff 0%, #ffffff 68%);
|
||||||
|
box-shadow: 0 10px 28px rgba(76, 29, 149, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-card.is-active {
|
||||||
|
border-color: #c4b5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-heading,
|
||||||
|
.quality-owner-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-heading h2 {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #312e81;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-heading p {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-card.is-active .quality-owner-status {
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #6d28d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-metrics div {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #ede9fe;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-metrics span,
|
||||||
|
.quality-owner-actions span {
|
||||||
|
color: #7b8492;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-metrics strong {
|
||||||
|
color: #312e81;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-actions :deep(.el-button--primary) {
|
||||||
|
border: none;
|
||||||
|
background: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-action-buttons :deep(.el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-pay-dialog-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-pay-amount {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-pay-amount strong {
|
||||||
|
color: #f97316;
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-pay-qr {
|
||||||
|
display: grid;
|
||||||
|
width: 228px;
|
||||||
|
height: 228px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-pay-qr img {
|
||||||
|
width: 214px;
|
||||||
|
height: 214px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-pay-dialog-body p {
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.profile-layout {
|
.profile-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quality-owner-metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.quality-owner-heading,
|
||||||
|
.quality-owner-actions {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-action-buttons {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-metrics {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-spotlight {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-spotlight-benefits {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface Listing {
|
|||||||
owner_id: number
|
owner_id: number
|
||||||
owner_phone?: string
|
owner_phone?: string
|
||||||
owner_nickname?: string
|
owner_nickname?: string
|
||||||
|
owner_is_quality_owner?: boolean
|
||||||
source_channel?: string
|
source_channel?: string
|
||||||
is_external_upload?: boolean
|
is_external_upload?: boolean
|
||||||
title: string
|
title: string
|
||||||
@@ -33,6 +34,9 @@ export interface Listing {
|
|||||||
distribution_site_id?: number
|
distribution_site_id?: number
|
||||||
distribution_site_code?: string
|
distribution_site_code?: string
|
||||||
distribution_site_name?: string
|
distribution_site_name?: string
|
||||||
|
is_premium: boolean
|
||||||
|
compensation_plan: 'normal' | 'insurance' | string
|
||||||
|
insurance_fee_bps: number
|
||||||
listing_group_conversation_id?: number
|
listing_group_conversation_id?: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
@@ -52,6 +56,8 @@ export interface ListingPayload {
|
|||||||
screenshot_urls: string[]
|
screenshot_urls: string[]
|
||||||
price_cent: number
|
price_cent: number
|
||||||
deposit_amount_cent: number
|
deposit_amount_cent: number
|
||||||
|
is_premium: boolean
|
||||||
|
compensation_plan: 'normal' | 'insurance'
|
||||||
agreed_virtual_asset_sale: boolean
|
agreed_virtual_asset_sale: boolean
|
||||||
agreed_seller_agreement: boolean
|
agreed_seller_agreement: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import { Coin, Discount, Grid, Key, Moon, Present } from '@element-plus/icons-vue'
|
import { Coin, Discount, Grid, Key, Moon, Present, Trophy } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
interface ZoneOption {
|
interface ZoneOption {
|
||||||
key: string
|
key: string
|
||||||
@@ -27,6 +27,7 @@ const zoneIcons: Record<string, Component> = {
|
|||||||
night: Moon,
|
night: Moon,
|
||||||
password: Key,
|
password: Key,
|
||||||
highCoin: Coin,
|
highCoin: Coin,
|
||||||
|
premium: Trophy,
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectZone(key: string) {
|
function selectZone(key: string) {
|
||||||
@@ -41,7 +42,7 @@ function selectZone(key: string) {
|
|||||||
:key="zone.key"
|
:key="zone.key"
|
||||||
type="button"
|
type="button"
|
||||||
class="zone-pill-item"
|
class="zone-pill-item"
|
||||||
:class="{ active: activeZone === zone.key }"
|
:class="{ active: activeZone === zone.key, 'is-premium': zone.key === 'premium' }"
|
||||||
:title="zone.hint"
|
:title="zone.hint"
|
||||||
@click="selectZone(zone.key)"
|
@click="selectZone(zone.key)"
|
||||||
>
|
>
|
||||||
@@ -89,6 +90,18 @@ function selectZone(key: string) {
|
|||||||
box-shadow: 0 2px 8px rgba(255, 106, 0, 0.25);
|
box-shadow: 0 2px 8px rgba(255, 106, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.zone-pill-item.is-premium:not(.active) {
|
||||||
|
border-color: #ddd6fe;
|
||||||
|
background: #faf7ff;
|
||||||
|
color: #6d28d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-pill-item.is-premium.active {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
background: linear-gradient(135deg, #6d28d9 0%, #9333ea 100%);
|
||||||
|
box-shadow: 0 4px 14px rgba(109, 40, 217, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
.zone-pill-icon {
|
.zone-pill-icon {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,14 @@ function formatStatNumber(value: number) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterLink class="hfb-listing-card" :to="`/listings/${listing.id}`">
|
<RouterLink
|
||||||
|
class="hfb-listing-card"
|
||||||
|
:class="{
|
||||||
|
'is-quality-owner': listing.owner_is_quality_owner,
|
||||||
|
'is-premium': listing.is_premium,
|
||||||
|
}"
|
||||||
|
:to="`/listings/${listing.id}`"
|
||||||
|
>
|
||||||
<!-- 左侧:4:3 黄金比例单张封面(后端公开缩略图,秒级加载) -->
|
<!-- 左侧:4:3 黄金比例单张封面(后端公开缩略图,秒级加载) -->
|
||||||
<div class="card-media">
|
<div class="card-media">
|
||||||
<img
|
<img
|
||||||
@@ -114,6 +121,9 @@ function formatStatNumber(value: number) {
|
|||||||
|
|
||||||
<!-- 状态浮标 -->
|
<!-- 状态浮标 -->
|
||||||
<div class="media-badges">
|
<div class="media-badges">
|
||||||
|
<span v-if="listing.owner_is_quality_owner" class="badge owner">优质号主</span>
|
||||||
|
<span v-if="listing.is_premium" class="badge premium">极品</span>
|
||||||
|
<span v-if="listing.compensation_plan === 'insurance'" class="badge insurance">保险</span>
|
||||||
<span v-if="hasAcceleratedSaleRatio(listing)" class="badge sale">特惠</span>
|
<span v-if="hasAcceleratedSaleRatio(listing)" class="badge sale">特惠</span>
|
||||||
<span v-if="hasGiftResources(listing)" class="badge gift">有赠送</span>
|
<span v-if="hasGiftResources(listing)" class="badge gift">有赠送</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,6 +138,10 @@ function formatStatNumber(value: number) {
|
|||||||
<div class="content-primary">
|
<div class="content-primary">
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<span class="code-badge">{{ listingCode }}</span>
|
<span class="code-badge">{{ listingCode }}</span>
|
||||||
|
<span v-if="listing.owner_is_quality_owner" class="owner-quality-badge">
|
||||||
|
<van-icon name="award-o" :size="13" />
|
||||||
|
<span>优质号主</span>
|
||||||
|
</span>
|
||||||
<span v-for="tag in accessTags" :key="tag.label" class="platform-badge" :class="tag.tone">
|
<span v-for="tag in accessTags" :key="tag.label" class="platform-badge" :class="tag.tone">
|
||||||
<van-icon :name="tag.icon" :size="13" />
|
<van-icon :name="tag.icon" :size="13" />
|
||||||
<span>{{ tag.label }}</span>
|
<span>{{ tag.label }}</span>
|
||||||
@@ -261,6 +275,7 @@ function formatStatNumber(value: number) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.hfb-listing-card {
|
.hfb-listing-card {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 210px minmax(0, 1fr) 146px;
|
grid-template-columns: 210px minmax(0, 1fr) 146px;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
@@ -279,6 +294,22 @@ function formatStatNumber(value: number) {
|
|||||||
transform 0.22s ease;
|
transform 0.22s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hfb-listing-card.is-quality-owner {
|
||||||
|
border-color: #d8c9fb;
|
||||||
|
box-shadow: 0 8px 24px rgba(109, 40, 217, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hfb-listing-card.is-quality-owner::before,
|
||||||
|
.hfb-listing-card.is-premium::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, #7c3aed 0%, #a855f7 50%, #f59e0b 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.hfb-listing-card:hover {
|
.hfb-listing-card:hover {
|
||||||
border-color: #ff8533;
|
border-color: #ff8533;
|
||||||
box-shadow: 0 12px 30px rgba(255, 106, 0, 0.12);
|
box-shadow: 0 12px 30px rgba(255, 106, 0, 0.12);
|
||||||
@@ -346,6 +377,18 @@ function formatStatNumber(value: number) {
|
|||||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge.owner {
|
||||||
|
background: linear-gradient(135deg, #6d28d9 0%, #9333ea 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.premium {
|
||||||
|
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.insurance {
|
||||||
|
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.media-ratio {
|
.media-ratio {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -465,6 +508,19 @@ function formatStatNumber(value: number) {
|
|||||||
color: #4f46e5;
|
color: #4f46e5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.owner-quality-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid #ddd6fe;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #6d28d9;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.aux-badge {
|
.aux-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type HomeBannerSlide,
|
type HomeBannerSlide,
|
||||||
} from '@/features/listings/api/homeConfig'
|
} from '@/features/listings/api/homeConfig'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { ArrowRight, Trophy } from '@element-plus/icons-vue'
|
||||||
import AnnouncementPopup from '@/features/announcement/components/AnnouncementPopup.vue'
|
import AnnouncementPopup from '@/features/announcement/components/AnnouncementPopup.vue'
|
||||||
import AuthImage from '@/shared/components/business/AuthImage.vue'
|
import AuthImage from '@/shared/components/business/AuthImage.vue'
|
||||||
import {
|
import {
|
||||||
@@ -206,6 +207,11 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const statCards = computed(() => [
|
const statCards = computed(() => [
|
||||||
|
{
|
||||||
|
label: '优质号主精选',
|
||||||
|
value: `${zoneCount('premium')}`,
|
||||||
|
hint: '认证优质号主 · 极品专区',
|
||||||
|
},
|
||||||
{ label: '可租账号', value: `${totalListings.value}`, hint: '当前筛选结果' },
|
{ label: '可租账号', value: `${totalListings.value}`, hint: '当前筛选结果' },
|
||||||
{
|
{
|
||||||
label: '高哈夫币',
|
label: '高哈夫币',
|
||||||
@@ -226,6 +232,12 @@ const zoneOptions = computed(() => [
|
|||||||
hint: '当前可租账号',
|
hint: '当前可租账号',
|
||||||
count: zoneCount('all'),
|
count: zoneCount('all'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'premium',
|
||||||
|
label: '极品专区',
|
||||||
|
hint: '优质号主精选',
|
||||||
|
count: zoneCount('premium'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'sale',
|
key: 'sale',
|
||||||
label: '特惠专区',
|
label: '特惠专区',
|
||||||
@@ -640,6 +652,25 @@ if (!restored) {
|
|||||||
<el-empty v-else description="暂无展示图片" />
|
<el-empty v-else description="暂无展示图片" />
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<section class="premium-spotlight" aria-label="优质号主精选">
|
||||||
|
<div class="premium-spotlight-mark">
|
||||||
|
<el-icon :size="24"><Trophy /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="premium-spotlight-copy">
|
||||||
|
<span>QUALITY OWNER SELECTION</span>
|
||||||
|
<strong>优质号主严选 · 极品专区</strong>
|
||||||
|
<p>仅认证优质号主可发布,尊享更严格的服务标准与无忧租号保险 4% 专享费率。</p>
|
||||||
|
</div>
|
||||||
|
<div class="premium-spotlight-count">
|
||||||
|
<strong>{{ zoneCount('premium') }}</strong>
|
||||||
|
<span>个精选账号</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="activeZone = 'premium'">
|
||||||
|
进入极品专区
|
||||||
|
<el-icon><ArrowRight /></el-icon>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<HomeFilters
|
<HomeFilters
|
||||||
:filters="filters"
|
:filters="filters"
|
||||||
:total-listings="totalListings"
|
:total-listings="totalListings"
|
||||||
@@ -714,6 +745,95 @@ if (!restored) {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.premium-spotlight {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
min-height: 92px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border: 1px solid #ded3fb;
|
||||||
|
border-radius: 14px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 82% 0%, rgba(245, 158, 11, 0.13), transparent 32%),
|
||||||
|
linear-gradient(115deg, #f7f3ff 0%, #ffffff 62%, #fffaf0 100%);
|
||||||
|
box-shadow: 0 8px 24px rgba(109, 40, 217, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight-mark {
|
||||||
|
display: grid;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 13px;
|
||||||
|
background: linear-gradient(135deg, #5b21b6 0%, #9333ea 62%, #f59e0b 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 18px rgba(109, 40, 217, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight-copy > span {
|
||||||
|
color: #7c3aed;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight-copy strong {
|
||||||
|
color: #2e1065;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight-copy p {
|
||||||
|
margin: 0;
|
||||||
|
color: #70677f;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight-count {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight-count strong {
|
||||||
|
color: #6d28d9;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight-count span {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #8b8299;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight > button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 15px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #6d28d9;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight > button:hover {
|
||||||
|
background: #5b21b6;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-section {
|
.hero-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 320px;
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
@@ -888,12 +1008,35 @@ if (!restored) {
|
|||||||
min-height: 76px;
|
min-height: 76px;
|
||||||
padding: 14px 40px 14px 16px;
|
padding: 14px 40px 14px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.premium-spotlight {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight > button {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
@media (max-width: 680px) {
|
||||||
.biz-entry-rail {
|
.biz-entry-rail {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.premium-spotlight {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight-count {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight > button {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.infinite-load-state {
|
.infinite-load-state {
|
||||||
|
|||||||
@@ -207,6 +207,16 @@
|
|||||||
color: #7c3aed;
|
color: #7c3aed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.platform-pill.premium {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #6d28d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-pill.insurance {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
.status-pill {
|
.status-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -462,6 +462,19 @@ function goBack() {
|
|||||||
<span>{{ listing.rank_level }}</span>
|
<span>{{ listing.rank_level }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<span v-if="listing.is_premium" class="platform-pill premium">
|
||||||
|
<van-icon name="award-o" :size="13" />
|
||||||
|
<span>极品专区</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="listing.compensation_plan === 'insurance'"
|
||||||
|
class="platform-pill insurance"
|
||||||
|
>
|
||||||
|
<van-icon name="shield-o" :size="13" />
|
||||||
|
<span>无忧租号保险</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
<span class="status-pill" :class="{ 'is-busy': listing.in_transaction }">
|
<span class="status-pill" :class="{ 'is-busy': listing.in_transaction }">
|
||||||
<i class="status-dot" />
|
<i class="status-dot" />
|
||||||
<span>{{ listing.in_transaction ? '交易中' : '现号可租' }}</span>
|
<span>{{ listing.in_transaction ? '交易中' : '现号可租' }}</span>
|
||||||
|
|||||||
@@ -564,6 +564,78 @@
|
|||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-premium-spotlight {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 68px;
|
||||||
|
margin: 2px 0 9px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #ddd0fb;
|
||||||
|
border-radius: 12px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 88% 0%, rgba(245, 158, 11, 0.16), transparent 34%),
|
||||||
|
linear-gradient(115deg, #f7f2ff 0%, #fff 68%, #fffaf0 100%);
|
||||||
|
color: #6d28d9;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #5b21b6 0%, #9333ea 65%, #f59e0b 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight-copy small {
|
||||||
|
color: #8b5cf6;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight-copy strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #2e1065;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight-copy em {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #7b7188;
|
||||||
|
font-size: 9px;
|
||||||
|
font-style: normal;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-spotlight-count {
|
||||||
|
display: grid;
|
||||||
|
min-width: 27px;
|
||||||
|
height: 27px;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #6d28d9;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
.zone-strip::-webkit-scrollbar {
|
.zone-strip::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -618,6 +690,23 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.zone-pill.is-premium:not(.active) {
|
||||||
|
border-color: #ddd6fe;
|
||||||
|
background: #faf7ff;
|
||||||
|
color: #6d28d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-pill.is-premium:not(.active) span {
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #6d28d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-pill.is-premium.active {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
background: #6d28d9;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.zone-pill:active {
|
.zone-pill:active {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
@@ -910,6 +999,18 @@
|
|||||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-badges .badge.owner {
|
||||||
|
background: linear-gradient(135deg, #6d28d9 0%, #9333ea 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-badges .badge.premium {
|
||||||
|
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-badges .badge.insurance {
|
||||||
|
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.media-ratio {
|
.media-ratio {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ const sortOptions: MobileSortOption[] = [
|
|||||||
|
|
||||||
const zoneOptions = [
|
const zoneOptions = [
|
||||||
{ key: 'all', label: '全部', hint: '当前可租' },
|
{ key: 'all', label: '全部', hint: '当前可租' },
|
||||||
|
{ key: 'premium', label: '极品', hint: '优质号主精选' },
|
||||||
{ key: 'sale', label: '特惠', hint: '价格更划算' },
|
{ key: 'sale', label: '特惠', hint: '价格更划算' },
|
||||||
{ key: 'gift', label: '赠送', hint: '含赠送物品' },
|
{ key: 'gift', label: '赠送', hint: '含赠送物品' },
|
||||||
{ key: 'night', label: '夜间', hint: '0-2点可响应上号' },
|
{ key: 'night', label: '夜间', hint: '0-2点可响应上号' },
|
||||||
@@ -1006,13 +1007,31 @@ if (!restoreHomeConfigFromCache()) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mobile-premium-spotlight"
|
||||||
|
aria-label="进入优质号主极品专区"
|
||||||
|
@click="selectZone('premium')"
|
||||||
|
>
|
||||||
|
<span class="premium-spotlight-icon">
|
||||||
|
<van-icon name="award-o" :size="22" />
|
||||||
|
</span>
|
||||||
|
<span class="premium-spotlight-copy">
|
||||||
|
<small>QUALITY OWNER</small>
|
||||||
|
<strong>优质号主严选 · 极品专区</strong>
|
||||||
|
<em>认证发布 · 4% 保险专享费率</em>
|
||||||
|
</span>
|
||||||
|
<span class="premium-spotlight-count">{{ zoneCount('premium') }}</span>
|
||||||
|
<van-icon name="arrow" :size="13" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="zone-strip" aria-label="账号专区">
|
<div class="zone-strip" aria-label="账号专区">
|
||||||
<button
|
<button
|
||||||
v-for="zone in visibleZoneOptions"
|
v-for="zone in visibleZoneOptions"
|
||||||
:key="zone.key"
|
:key="zone.key"
|
||||||
type="button"
|
type="button"
|
||||||
class="zone-pill"
|
class="zone-pill"
|
||||||
:class="{ active: activeZone === zone.key }"
|
:class="{ active: activeZone === zone.key, 'is-premium': zone.key === 'premium' }"
|
||||||
@click="selectZone(zone.key)"
|
@click="selectZone(zone.key)"
|
||||||
>
|
>
|
||||||
<strong>{{ zone.label }}</strong>
|
<strong>{{ zone.label }}</strong>
|
||||||
@@ -1122,9 +1141,20 @@ if (!restoreHomeConfigFromCache()) {
|
|||||||
<div v-else class="media-empty">HFB</div>
|
<div v-else class="media-empty">HFB</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="hasGiftResources(item) || hasAcceleratedSaleRatio(item)"
|
v-if="
|
||||||
|
hasGiftResources(item) ||
|
||||||
|
hasAcceleratedSaleRatio(item) ||
|
||||||
|
item.owner_is_quality_owner ||
|
||||||
|
item.is_premium ||
|
||||||
|
item.compensation_plan === 'insurance'
|
||||||
|
"
|
||||||
class="media-badges"
|
class="media-badges"
|
||||||
>
|
>
|
||||||
|
<span v-if="item.owner_is_quality_owner" class="badge owner">优质号主</span>
|
||||||
|
<span v-if="item.is_premium" class="badge premium">极品</span>
|
||||||
|
<span v-if="item.compensation_plan === 'insurance'" class="badge insurance"
|
||||||
|
>保险</span
|
||||||
|
>
|
||||||
<span v-if="hasAcceleratedSaleRatio(item)" class="badge sale">特惠</span>
|
<span v-if="hasAcceleratedSaleRatio(item)" class="badge sale">特惠</span>
|
||||||
<span v-if="hasGiftResources(item)" class="badge gift">有赠送</span>
|
<span v-if="hasGiftResources(item)" class="badge gift">有赠送</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -195,6 +195,16 @@
|
|||||||
color: #7c3aed;
|
color: #7c3aed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.platform-pill.premium {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #6d28d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-pill.insurance {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
.status-pill {
|
.status-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -444,6 +444,14 @@ function goBack() {
|
|||||||
<van-icon name="diamond-o" :size="12" />
|
<van-icon name="diamond-o" :size="12" />
|
||||||
<span>{{ listing.rank_level }}</span>
|
<span>{{ listing.rank_level }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="listing.is_premium" class="platform-pill premium">
|
||||||
|
<van-icon name="award-o" :size="12" />
|
||||||
|
<span>极品专区</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="listing.compensation_plan === 'insurance'" class="platform-pill insurance">
|
||||||
|
<van-icon name="shield-o" :size="12" />
|
||||||
|
<span>无忧保险</span>
|
||||||
|
</span>
|
||||||
<span class="status-pill" :class="{ 'is-busy': listing.in_transaction }">
|
<span class="status-pill" :class="{ 'is-busy': listing.in_transaction }">
|
||||||
<i class="status-dot" />
|
<i class="status-dot" />
|
||||||
<span>{{ listing.in_transaction ? '交易中' : '现号可租' }}</span>
|
<span>{{ listing.in_transaction ? '交易中' : '现号可租' }}</span>
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export interface Order {
|
|||||||
account_snapshot?: Record<string, unknown>
|
account_snapshot?: Record<string, unknown>
|
||||||
account_source?: 'internal' | 'external' | string
|
account_source?: 'internal' | 'external' | string
|
||||||
source_channel?: string
|
source_channel?: string
|
||||||
|
is_premium?: boolean
|
||||||
|
compensation_plan?: 'normal' | 'insurance' | string
|
||||||
|
insurance_fee_bps?: number
|
||||||
|
insurance_fee_cent?: number
|
||||||
listing_snapshot?: string
|
listing_snapshot?: string
|
||||||
checkout_info?: string
|
checkout_info?: string
|
||||||
counter_info?: string
|
counter_info?: string
|
||||||
@@ -174,6 +178,8 @@ export interface Checkout {
|
|||||||
rent_amount_cent?: number
|
rent_amount_cent?: number
|
||||||
owner_rent_amount_cent?: number
|
owner_rent_amount_cent?: number
|
||||||
platform_fee_cent?: number
|
platform_fee_cent?: number
|
||||||
|
insurance_fee_bps?: number
|
||||||
|
insurance_fee_cent?: number
|
||||||
deposit_amount_cent: number
|
deposit_amount_cent: number
|
||||||
pure_coin_amount_cent: number
|
pure_coin_amount_cent: number
|
||||||
pure_coin_discount_cent: number
|
pure_coin_discount_cent: number
|
||||||
@@ -234,12 +240,14 @@ export interface PaymentOrder {
|
|||||||
payment_no: string
|
payment_no: string
|
||||||
order_id: number
|
order_id: number
|
||||||
order_no: string
|
order_no: string
|
||||||
|
biz_id?: number
|
||||||
provider: string
|
provider: string
|
||||||
third_order_id: string
|
third_order_id: string
|
||||||
provider_order_id: string
|
provider_order_id: string
|
||||||
pay_way: string
|
pay_way: string
|
||||||
jspay_flag: string
|
jspay_flag: string
|
||||||
amount_cent: number
|
amount_cent: number
|
||||||
|
biz_type?: string
|
||||||
status: string
|
status: string
|
||||||
td_code?: string
|
td_code?: string
|
||||||
jspay_url?: string
|
jspay_url?: string
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ const checkoutContent = computed(() => splitCheckoutContent(props.order.checkout
|
|||||||
:value="`¥${money(amountYuan(checkout.renter_refund_amount_cent))}`"
|
:value="`¥${money(amountYuan(checkout.renter_refund_amount_cent))}`"
|
||||||
value-class="green-text"
|
value-class="green-text"
|
||||||
/>
|
/>
|
||||||
|
<van-cell
|
||||||
|
v-if="props.isOwner && Number(checkout.insurance_fee_cent || 0) > 0"
|
||||||
|
title="无忧租号保险服务费"
|
||||||
|
:label="`费率 ${(Number(checkout.insurance_fee_bps || 0) / 100).toFixed(1)}%`"
|
||||||
|
:value="`-¥${money(amountYuan(checkout.insurance_fee_cent))}`"
|
||||||
|
value-class="red-text"
|
||||||
|
/>
|
||||||
<van-cell
|
<van-cell
|
||||||
v-if="props.isOwner"
|
v-if="props.isOwner"
|
||||||
title="号主最终收入"
|
title="号主最终收入"
|
||||||
@@ -186,6 +193,15 @@ const checkoutContent = computed(() => splitCheckoutContent(props.order.checkout
|
|||||||
<span>退还租客(未使用租金 + 剩余押金)</span>
|
<span>退还租客(未使用租金 + 剩余押金)</span>
|
||||||
<strong class="amount">¥{{ money(amountYuan(checkout.renter_refund_amount_cent)) }}</strong>
|
<strong class="amount">¥{{ money(amountYuan(checkout.renter_refund_amount_cent)) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="props.isOwner && Number(checkout.insurance_fee_cent || 0) > 0"
|
||||||
|
class="summary-row highlight"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
无忧租号保险服务费({{ (Number(checkout.insurance_fee_bps || 0) / 100).toFixed(1) }}%)
|
||||||
|
</span>
|
||||||
|
<strong class="warning">-¥{{ money(amountYuan(checkout.insurance_fee_cent)) }}</strong>
|
||||||
|
</div>
|
||||||
<div v-if="props.isOwner" class="summary-row highlight">
|
<div v-if="props.isOwner" class="summary-row highlight">
|
||||||
<span>号主最终收入(租金 + 押金赔付)</span>
|
<span>号主最终收入(租金 + 押金赔付)</span>
|
||||||
<strong class="amount">¥{{ money(amountYuan(checkout.owner_income_amount_cent)) }}</strong>
|
<strong class="amount">¥{{ money(amountYuan(checkout.owner_income_amount_cent)) }}</strong>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface OwnerSimulation {
|
|||||||
role: 'owner'
|
role: 'owner'
|
||||||
totalIncomeYuan: number
|
totalIncomeYuan: number
|
||||||
rentIncomeYuan: number
|
rentIncomeYuan: number
|
||||||
|
insuranceFeeYuan: number
|
||||||
damageCompensationYuan: number
|
damageCompensationYuan: number
|
||||||
lossCompensationYuan: number
|
lossCompensationYuan: number
|
||||||
shortfallYuan: number
|
shortfallYuan: number
|
||||||
@@ -99,7 +100,14 @@ export function useCheckoutSimulator(options: {
|
|||||||
ownerConsumableYuan = consumableYuan
|
ownerConsumableYuan = consumableYuan
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerRentIncomeYuan = roundMoney(ownerCoinYuan + ownerConsumableYuan)
|
const grossOwnerRentIncomeYuan = roundMoney(ownerCoinYuan + ownerConsumableYuan)
|
||||||
|
const insuranceFeeYuan = roundMoney(
|
||||||
|
(grossOwnerRentIncomeYuan * Number(o.insurance_fee_bps || 0)) / 10000
|
||||||
|
)
|
||||||
|
const ownerRentIncomeYuan = Math.max(
|
||||||
|
roundMoney(grossOwnerRentIncomeYuan - insuranceFeeYuan),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
// 押金赔付覆盖与差额
|
// 押金赔付覆盖与差额
|
||||||
const damageCoveredYuan = Math.min(depositDeductYuan, depositYuan)
|
const damageCoveredYuan = Math.min(depositDeductYuan, depositYuan)
|
||||||
@@ -110,6 +118,7 @@ export function useCheckoutSimulator(options: {
|
|||||||
role: 'owner',
|
role: 'owner',
|
||||||
totalIncomeYuan,
|
totalIncomeYuan,
|
||||||
rentIncomeYuan: ownerRentIncomeYuan,
|
rentIncomeYuan: ownerRentIncomeYuan,
|
||||||
|
insuranceFeeYuan,
|
||||||
damageCompensationYuan: damageCoveredYuan,
|
damageCompensationYuan: damageCoveredYuan,
|
||||||
lossCompensationYuan: ownerConsumableYuan,
|
lossCompensationYuan: ownerConsumableYuan,
|
||||||
shortfallYuan,
|
shortfallYuan,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
import type { ApiResponse } from '@/shared/types/types'
|
||||||
|
import type { PaymentOrder, PaymentPayWay } from '@/features/orders/api/orders'
|
||||||
|
|
||||||
|
export interface QualityOwnerStatus {
|
||||||
|
user_id: number
|
||||||
|
active: boolean
|
||||||
|
status: 'none' | 'active' | string
|
||||||
|
deposit_amount_cent: number
|
||||||
|
deposit_source: 'paid' | 'manual' | '' | string
|
||||||
|
activated_at?: string
|
||||||
|
available_balance_cent: number
|
||||||
|
required_deposit_cent: number
|
||||||
|
insurance_fee_bps: number
|
||||||
|
premium_insurance_fee_bps: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchQualityOwnerStatus() {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<QualityOwnerStatus>>('/quality-owner/status')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateQualityOwner() {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<QualityOwnerStatus>>('/quality-owner/activate')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startQualityOwnerPayment(payWay: PaymentPayWay) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>('/quality-owner/payment', {
|
||||||
|
pay_way: payWay,
|
||||||
|
})
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryQualityOwnerPayment() {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<PaymentOrder>>('/quality-owner/payment')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ export function defaultPublishForm(): PublishForm {
|
|||||||
ban_record: '',
|
ban_record: '',
|
||||||
common_regions: [],
|
common_regions: [],
|
||||||
depositAmountYuan: '',
|
depositAmountYuan: '',
|
||||||
|
is_premium: false,
|
||||||
|
compensation_plan: 'normal',
|
||||||
remark: '',
|
remark: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,6 +103,8 @@ function normalizeDraftForm(value: unknown): PublishForm {
|
|||||||
if (next.haf_coin_amount === 0) next.haf_coin_amount = ''
|
if (next.haf_coin_amount === 0) next.haf_coin_amount = ''
|
||||||
if (String(next.secret_kd).trim() === '0') next.secret_kd = ''
|
if (String(next.secret_kd).trim() === '0') next.secret_kd = ''
|
||||||
if (next.fire_level === 0) next.fire_level = ''
|
if (next.fire_level === 0) next.fire_level = ''
|
||||||
|
next.is_premium = next.is_premium === true
|
||||||
|
if (next.compensation_plan !== 'insurance') next.compensation_plan = 'normal'
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import {
|
|||||||
updateListing,
|
updateListing,
|
||||||
type Listing,
|
type Listing,
|
||||||
} from '@/features/listings/api/listings'
|
} from '@/features/listings/api/listings'
|
||||||
|
import {
|
||||||
|
fetchQualityOwnerStatus,
|
||||||
|
type QualityOwnerStatus,
|
||||||
|
} from '@/features/quality-owner/api/qualityOwner'
|
||||||
import { usePricingCalculator } from '@/shared/composables/usePricingCalculator'
|
import { usePricingCalculator } from '@/shared/composables/usePricingCalculator'
|
||||||
import {
|
import {
|
||||||
buildPublishDraft,
|
buildPublishDraft,
|
||||||
@@ -66,6 +70,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
|
const publishOptions = ref<ListingPublishOptions>(emptyListingPublishOptions)
|
||||||
const salePriceConfig = ref<PublishSalePriceConfig>(emptyListingSalePriceConfig)
|
const salePriceConfig = ref<PublishSalePriceConfig>(emptyListingSalePriceConfig)
|
||||||
const publishAgreements = ref<ListingPublishAgreements>(emptyListingPublishAgreements)
|
const publishAgreements = ref<ListingPublishAgreements>(emptyListingPublishAgreements)
|
||||||
|
const qualityOwnerStatus = ref<QualityOwnerStatus | null>(null)
|
||||||
const virtualAssetSaleAgreementChecked = ref(false)
|
const virtualAssetSaleAgreementChecked = ref(false)
|
||||||
const sellerAgreementChecked = ref(false)
|
const sellerAgreementChecked = ref(false)
|
||||||
const passwordAndDeviceConfirmed = ref(false)
|
const passwordAndDeviceConfirmed = ref(false)
|
||||||
@@ -86,6 +91,13 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
const showBanRecordRiskHint = false
|
const showBanRecordRiskHint = false
|
||||||
const unlockDemulanEnabled = computed(() => publishOptions.value.unlock_demulan_enabled)
|
const unlockDemulanEnabled = computed(() => publishOptions.value.unlock_demulan_enabled)
|
||||||
const nextSeasonPassEnabled = computed(() => publishOptions.value.next_season_pass_enabled)
|
const nextSeasonPassEnabled = computed(() => publishOptions.value.next_season_pass_enabled)
|
||||||
|
const isQualityOwner = computed(() => qualityOwnerStatus.value?.active === true)
|
||||||
|
const insuranceFeeBps = computed(() =>
|
||||||
|
form.is_premium && isQualityOwner.value
|
||||||
|
? Number(qualityOwnerStatus.value?.premium_insurance_fee_bps || 400)
|
||||||
|
: Number(qualityOwnerStatus.value?.insurance_fee_bps || 800)
|
||||||
|
)
|
||||||
|
const insuranceFeePercent = computed(() => insuranceFeeBps.value / 100)
|
||||||
|
|
||||||
const pricing = usePricingCalculator({
|
const pricing = usePricingCalculator({
|
||||||
form,
|
form,
|
||||||
@@ -184,14 +196,17 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
|
|
||||||
async function loadPublishOptions() {
|
async function loadPublishOptions() {
|
||||||
try {
|
try {
|
||||||
const [nextPublishOptions, nextSalePriceConfig, nextPublishAgreements] = await Promise.all([
|
const [nextPublishOptions, nextSalePriceConfig, nextPublishAgreements, nextQualityOwner] =
|
||||||
fetchListingPublishOptions(),
|
await Promise.all([
|
||||||
fetchListingSalePriceConfig(),
|
fetchListingPublishOptions(),
|
||||||
fetchListingPublishAgreements(),
|
fetchListingSalePriceConfig(),
|
||||||
])
|
fetchListingPublishAgreements(),
|
||||||
|
fetchQualityOwnerStatus().catch(() => null),
|
||||||
|
])
|
||||||
publishOptions.value = nextPublishOptions
|
publishOptions.value = nextPublishOptions
|
||||||
salePriceConfig.value = nextSalePriceConfig
|
salePriceConfig.value = nextSalePriceConfig
|
||||||
publishAgreements.value = nextPublishAgreements
|
publishAgreements.value = nextPublishAgreements
|
||||||
|
qualityOwnerStatus.value = nextQualityOwner
|
||||||
|
|
||||||
// 默认数字都是0
|
// 默认数字都是0
|
||||||
for (const item of nextPublishOptions.quantity_items) {
|
for (const item of nextPublishOptions.quantity_items) {
|
||||||
@@ -203,6 +218,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
publishOptions.value = emptyListingPublishOptions
|
publishOptions.value = emptyListingPublishOptions
|
||||||
salePriceConfig.value = emptyListingSalePriceConfig
|
salePriceConfig.value = emptyListingSalePriceConfig
|
||||||
publishAgreements.value = emptyListingPublishAgreements
|
publishAgreements.value = emptyListingPublishAgreements
|
||||||
|
qualityOwnerStatus.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +342,8 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
ban_record: stringValue(summary.ban_record),
|
ban_record: stringValue(summary.ban_record),
|
||||||
common_regions: stringArray(summary.common_regions),
|
common_regions: stringArray(summary.common_regions),
|
||||||
depositAmountYuan: listing.deposit_amount_cent > 0 ? listing.deposit_amount_cent / 100 : '',
|
depositAmountYuan: listing.deposit_amount_cent > 0 ? listing.deposit_amount_cent / 100 : '',
|
||||||
|
is_premium: listing.is_premium === true,
|
||||||
|
compensation_plan: listing.compensation_plan === 'insurance' ? 'insurance' : 'normal',
|
||||||
remark: listing.description || stringValue(summary.remark),
|
remark: listing.description || stringValue(summary.remark),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -662,6 +680,8 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
screenshot_urls: pricing.screenshotUrls.value,
|
screenshot_urls: pricing.screenshotUrls.value,
|
||||||
price_cent: yuanToCent(pricing.calculatedFinalPrice.value),
|
price_cent: yuanToCent(pricing.calculatedFinalPrice.value),
|
||||||
deposit_amount_cent: yuanToCent(Number(form.depositAmountYuan)),
|
deposit_amount_cent: yuanToCent(Number(form.depositAmountYuan)),
|
||||||
|
is_premium: form.is_premium,
|
||||||
|
compensation_plan: form.compensation_plan,
|
||||||
agreed_virtual_asset_sale: virtualAssetSaleAgreementChecked.value,
|
agreed_virtual_asset_sale: virtualAssetSaleAgreementChecked.value,
|
||||||
agreed_seller_agreement: sellerAgreementChecked.value,
|
agreed_seller_agreement: sellerAgreementChecked.value,
|
||||||
}
|
}
|
||||||
@@ -758,6 +778,7 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
if (!pricing.calculatedFinalPrice.value) return '请完善币数、保险、体力和负重后再发布'
|
if (!pricing.calculatedFinalPrice.value) return '请完善币数、保险、体力和负重后再发布'
|
||||||
if (!Number.isFinite(pricing.calculatedFinalPrice.value))
|
if (!Number.isFinite(pricing.calculatedFinalPrice.value))
|
||||||
return '发布价格计算异常,请检查填写内容'
|
return '发布价格计算异常,请检查填写内容'
|
||||||
|
if (form.is_premium && !isQualityOwner.value) return '仅优质号主可以发布到极品专区'
|
||||||
if (!virtualAssetSaleAgreementChecked.value || !sellerAgreementChecked.value) {
|
if (!virtualAssetSaleAgreementChecked.value || !sellerAgreementChecked.value) {
|
||||||
return '请先阅读并勾选两份发布协议'
|
return '请先阅读并勾选两份发布协议'
|
||||||
}
|
}
|
||||||
@@ -932,6 +953,10 @@ export function usePublishForm(options: UsePublishFormOptions) {
|
|||||||
loading,
|
loading,
|
||||||
uploading,
|
uploading,
|
||||||
publishAgreements,
|
publishAgreements,
|
||||||
|
qualityOwnerStatus,
|
||||||
|
isQualityOwner,
|
||||||
|
insuranceFeeBps,
|
||||||
|
insuranceFeePercent,
|
||||||
virtualAssetSaleAgreementChecked,
|
virtualAssetSaleAgreementChecked,
|
||||||
sellerAgreementChecked,
|
sellerAgreementChecked,
|
||||||
passwordAndDeviceConfirmed,
|
passwordAndDeviceConfirmed,
|
||||||
|
|||||||
@@ -870,6 +870,100 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.premium-coverage-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-zone-row,
|
||||||
|
.coverage-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-zone-row strong,
|
||||||
|
.coverage-heading strong {
|
||||||
|
display: block;
|
||||||
|
color: #20242a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-zone-row span,
|
||||||
|
.coverage-heading span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #858c96;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-link {
|
||||||
|
justify-self: start;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #7c3aed;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-option-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-option {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 5px;
|
||||||
|
min-height: 82px;
|
||||||
|
padding: 11px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #20242a;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-option strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-option span {
|
||||||
|
color: #858c96;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-option em {
|
||||||
|
color: #7c3aed;
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-option.active {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
background: #faf5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-note {
|
||||||
|
margin: 0;
|
||||||
|
color: #858c96;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
.publish-agreement-card {
|
.publish-agreement-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ const {
|
|||||||
sellerAgreementChecked,
|
sellerAgreementChecked,
|
||||||
passwordAndDeviceConfirmed,
|
passwordAndDeviceConfirmed,
|
||||||
canPublishAfterAgreements,
|
canPublishAfterAgreements,
|
||||||
|
isQualityOwner,
|
||||||
|
qualityOwnerStatus,
|
||||||
|
insuranceFeePercent,
|
||||||
showBanRecordRiskHint,
|
showBanRecordRiskHint,
|
||||||
unlockDemulanEnabled,
|
unlockDemulanEnabled,
|
||||||
nextSeasonPassEnabled,
|
nextSeasonPassEnabled,
|
||||||
@@ -821,6 +824,61 @@ const screenshotGuides: Record<string, string> = {
|
|||||||
</div>
|
</div>
|
||||||
<p class="price-breakdown-hint">卖家价格 = 纯币基础价 + 额外消耗品。</p>
|
<p class="price-breakdown-hint">卖家价格 = 纯币基础价 + 额外消耗品。</p>
|
||||||
|
|
||||||
|
<div class="premium-coverage-card">
|
||||||
|
<div class="premium-zone-row">
|
||||||
|
<div>
|
||||||
|
<strong>极品专区</strong>
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
isQualityOwner
|
||||||
|
? '优质号主专享曝光专区'
|
||||||
|
: `保证金 ¥${formatMoney(
|
||||||
|
(qualityOwnerStatus?.required_deposit_cent || 20000) / 100
|
||||||
|
)}`
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<van-switch v-model="form.is_premium" :disabled="!isQualityOwner" size="22px" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="!isQualityOwner"
|
||||||
|
type="button"
|
||||||
|
class="quality-owner-link"
|
||||||
|
@click="router.push('/m/profile')"
|
||||||
|
>
|
||||||
|
前往个人中心开通优质号主
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="coverage-heading">
|
||||||
|
<strong>选择赔付方案</strong>
|
||||||
|
<span>服务费按实际到手租金结算扣除</span>
|
||||||
|
</div>
|
||||||
|
<div class="coverage-option-grid">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="coverage-option"
|
||||||
|
:class="{ active: form.compensation_plan === 'normal' }"
|
||||||
|
@click="form.compensation_plan = 'normal'"
|
||||||
|
>
|
||||||
|
<strong>普通赔付</strong>
|
||||||
|
<span>租金 + 押金规则赔付</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="coverage-option"
|
||||||
|
:class="{ active: form.compensation_plan === 'insurance' }"
|
||||||
|
@click="form.compensation_plan = 'insurance'"
|
||||||
|
>
|
||||||
|
<strong>无忧租号保险</strong>
|
||||||
|
<span>服务费 {{ insuranceFeePercent.toFixed(1) }}%</span>
|
||||||
|
<em v-if="isQualityOwner && form.is_premium">极品专享费率</em>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="coverage-note">
|
||||||
|
无忧租号保险在普通赔付基础上额外保障账号市场价值;选择后按号主实际到手租金扣除服务费。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<van-field
|
<van-field
|
||||||
v-model="form.remark"
|
v-model="form.remark"
|
||||||
label="备注"
|
label="备注"
|
||||||
|
|||||||
@@ -1012,6 +1012,108 @@
|
|||||||
background-color: var(--color-orange-dark);
|
background-color: var(--color-orange-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.premium-coverage-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-zone-row,
|
||||||
|
.coverage-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-zone-row > div,
|
||||||
|
.coverage-heading {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-zone-row strong,
|
||||||
|
.coverage-heading strong {
|
||||||
|
display: block;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-zone-row span,
|
||||||
|
.coverage-heading span {
|
||||||
|
color: #7b8492;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-owner-link {
|
||||||
|
justify-self: start;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #7c3aed;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-option-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-option {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 92px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1f2937;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-option strong {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-option span {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-option em {
|
||||||
|
color: #7c3aed;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-option.active {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
background: #faf5ff;
|
||||||
|
box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-note {
|
||||||
|
margin: 0;
|
||||||
|
color: #7b8492;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1120px) {
|
@media (max-width: 1120px) {
|
||||||
.publish-layout {
|
.publish-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -1094,4 +1196,8 @@
|
|||||||
.region-grid {
|
.region-grid {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.coverage-option-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ const {
|
|||||||
formatNumber,
|
formatNumber,
|
||||||
loading,
|
loading,
|
||||||
uploading,
|
uploading,
|
||||||
|
router,
|
||||||
publishAgreements,
|
publishAgreements,
|
||||||
|
isQualityOwner,
|
||||||
|
qualityOwnerStatus,
|
||||||
|
insuranceFeePercent,
|
||||||
virtualAssetSaleAgreementChecked,
|
virtualAssetSaleAgreementChecked,
|
||||||
sellerAgreementChecked,
|
sellerAgreementChecked,
|
||||||
passwordAndDeviceConfirmed,
|
passwordAndDeviceConfirmed,
|
||||||
@@ -653,6 +657,65 @@ function selectDailyLoss(value: string | number) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="premium-coverage-panel">
|
||||||
|
<div class="premium-zone-row">
|
||||||
|
<div>
|
||||||
|
<strong>极品专区</strong>
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
isQualityOwner
|
||||||
|
? '优质号主专享曝光专区'
|
||||||
|
: `缴纳 ¥${formatMoney(
|
||||||
|
(qualityOwnerStatus?.required_deposit_cent || 20000) / 100
|
||||||
|
)} 保证金后可开通优质号主`
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<el-switch
|
||||||
|
v-model="form.is_premium"
|
||||||
|
:disabled="!isQualityOwner"
|
||||||
|
active-text="发布到极品专区"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="!isQualityOwner"
|
||||||
|
type="button"
|
||||||
|
class="quality-owner-link"
|
||||||
|
@click="router.push('/profile')"
|
||||||
|
>
|
||||||
|
前往个人中心开通优质号主
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="coverage-heading">
|
||||||
|
<strong>选择赔付方案</strong>
|
||||||
|
<span>服务费按号主实际到手租金计算,结算时自动扣除</span>
|
||||||
|
</div>
|
||||||
|
<div class="coverage-option-grid">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="coverage-option"
|
||||||
|
:class="{ active: form.compensation_plan === 'normal' }"
|
||||||
|
@click="form.compensation_plan = 'normal'"
|
||||||
|
>
|
||||||
|
<strong>普通赔付</strong>
|
||||||
|
<span>按平台规则赔付租金与押金</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="coverage-option"
|
||||||
|
:class="{ active: form.compensation_plan === 'insurance' }"
|
||||||
|
@click="form.compensation_plan = 'insurance'"
|
||||||
|
>
|
||||||
|
<strong>无忧租号保险</strong>
|
||||||
|
<span> 服务费 {{ insuranceFeePercent.toFixed(1) }}%,额外保障账号市场价值 </span>
|
||||||
|
<em v-if="isQualityOwner && form.is_premium">极品专区专享费率</em>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="coverage-note">
|
||||||
|
普通赔付:账号封禁、追回、洗号等情况最高赔付全额租金与押金。无忧租号保险:在普通赔付基础上,额外按账号市场价值赔付;选择后按实际到手租金扣除服务费。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="input-block remark">
|
<label class="input-block remark">
|
||||||
<span>备注</span>
|
<span>备注</span>
|
||||||
<el-input
|
<el-input
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const walletMetrics = computed(() => {
|
|||||||
{
|
{
|
||||||
label: '冻结余额',
|
label: '冻结余额',
|
||||||
value: formatCentWithSymbol(account.value.frozen_balance_cent),
|
value: formatCentWithSymbol(account.value.frozen_balance_cent),
|
||||||
hint: '当前暂无冻结资金使用',
|
hint: '提现冻结及优质号主保证金',
|
||||||
icon: Lock,
|
icon: Lock,
|
||||||
tone: 'frozen',
|
tone: 'frozen',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -356,6 +356,22 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pc-quality-owner-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 1px solid #d8b4fe;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(135deg, #6d28d9 0%, #9333ea 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.pc-realname-badge {
|
.pc-realname-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -231,7 +231,12 @@ async function handleSupportClick() {
|
|||||||
<span v-else class="pc-avatar-text">{{ session.avatarText }}</span>
|
<span v-else class="pc-avatar-text">{{ session.avatarText }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="pc-user-name">{{ session.displayName }}</span>
|
<span class="pc-user-name">{{ session.displayName }}</span>
|
||||||
<span v-if="siteStore.isDistribution" class="pc-growth-badge is-distribution">
|
<span
|
||||||
|
v-if="session.isQualityOwner && !siteStore.isDistribution"
|
||||||
|
class="pc-quality-owner-badge"
|
||||||
|
>优质号主</span
|
||||||
|
>
|
||||||
|
<span v-else-if="siteStore.isDistribution" class="pc-growth-badge is-distribution">
|
||||||
会员专享
|
会员专享
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="pc-growth-badge" :class="`is-${growthBadgeTone}`">
|
<span v-else class="pc-growth-badge" :class="`is-${growthBadgeTone}`">
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export type PublishForm = {
|
|||||||
ban_record: string
|
ban_record: string
|
||||||
common_regions: string[]
|
common_regions: string[]
|
||||||
depositAmountYuan: number | ''
|
depositAmountYuan: number | ''
|
||||||
|
/** 是否发布到极品专区(仅优质号主可用) */
|
||||||
|
is_premium: boolean
|
||||||
|
/** 普通赔付或不额外购买无忧租号保险 */
|
||||||
|
compensation_plan: 'normal' | 'insurance'
|
||||||
remark: string
|
remark: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export const AUDIT_ACTION_OPTIONS: AuditOption[] = [
|
|||||||
{ value: 'admin_user.manual_realname', label: '人工实名', group: '用户', highRisk: true },
|
{ value: 'admin_user.manual_realname', label: '人工实名', group: '用户', highRisk: true },
|
||||||
{ value: 'admin_user.revoke_realname', label: '撤销实名', group: '用户', highRisk: true },
|
{ value: 'admin_user.revoke_realname', label: '撤销实名', group: '用户', highRisk: true },
|
||||||
{ value: 'admin_user.wallet_adjust', label: '调整用户余额', group: '用户', highRisk: true },
|
{ value: 'admin_user.wallet_adjust', label: '调整用户余额', group: '用户', highRisk: true },
|
||||||
|
{ value: 'admin_user.set_quality_owner', label: '设置优质号主', group: '用户', highRisk: true },
|
||||||
|
{ value: 'quality_owner.activate', label: '缴纳优质号主保证金', group: '用户', highRisk: true },
|
||||||
// 商品
|
// 商品
|
||||||
{ value: 'listing.admin_offline', label: '后台下架商品', group: '商品', highRisk: true },
|
{ value: 'listing.admin_offline', label: '后台下架商品', group: '商品', highRisk: true },
|
||||||
{ value: 'listing.mark_abnormal', label: '商品标记异常', group: '商品', highRisk: true },
|
{ value: 'listing.mark_abnormal', label: '商品标记异常', group: '商品', highRisk: true },
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ export interface AuthTokenPair {
|
|||||||
const userKeys = {
|
const userKeys = {
|
||||||
accessToken: 'access_token',
|
accessToken: 'access_token',
|
||||||
refreshToken: 'refresh_token',
|
refreshToken: 'refresh_token',
|
||||||
profile: ['user_id', 'phone', 'nickname', 'avatar_url', 'realname_status'],
|
profile: [
|
||||||
|
'user_id',
|
||||||
|
'phone',
|
||||||
|
'nickname',
|
||||||
|
'avatar_url',
|
||||||
|
'realname_status',
|
||||||
|
'quality_owner_status',
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminKeys = {
|
const adminKeys = {
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ const walletBizTypeMap: Record<string, string> = {
|
|||||||
admin_debit: '管理员扣款',
|
admin_debit: '管理员扣款',
|
||||||
admin_pickup_settle: '提号结算',
|
admin_pickup_settle: '提号结算',
|
||||||
admin_pickup_adjustment: '提号结算调整',
|
admin_pickup_adjustment: '提号结算调整',
|
||||||
|
quality_owner_deposit: '优质号主保证金',
|
||||||
|
quality_owner_deposit_refund: '优质号主保证金退回',
|
||||||
// 营销
|
// 营销
|
||||||
invite_rebate: '邀请返利',
|
invite_rebate: '邀请返利',
|
||||||
// 分销
|
// 分销
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const useSessionStore = defineStore('session', {
|
|||||||
realnameStatus: localStorage.getItem('realname_status') || 'unknown',
|
realnameStatus: localStorage.getItem('realname_status') || 'unknown',
|
||||||
renterGrowthPoints: Number(localStorage.getItem('renter_growth_points') || 0),
|
renterGrowthPoints: Number(localStorage.getItem('renter_growth_points') || 0),
|
||||||
renterGrowthLevel: localStorage.getItem('renter_growth_level') || 'normal',
|
renterGrowthLevel: localStorage.getItem('renter_growth_level') || 'normal',
|
||||||
|
qualityOwnerStatus: localStorage.getItem('quality_owner_status') || 'none',
|
||||||
_loadingMe: false, // 防止重复调用
|
_loadingMe: false, // 防止重复调用
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
@@ -44,6 +45,7 @@ export const useSessionStore = defineStore('session', {
|
|||||||
state.nickname || (state.phone ? `用户${state.phone.slice(-6)}` : `用户${state.userId || 0}`),
|
state.nickname || (state.phone ? `用户${state.phone.slice(-6)}` : `用户${state.userId || 0}`),
|
||||||
avatarText: state => (state.nickname || state.phone || 'U').slice(0, 1),
|
avatarText: state => (state.nickname || state.phone || 'U').slice(0, 1),
|
||||||
renterGrowthLevelName: state => renterGrowthLevelName(state.renterGrowthLevel),
|
renterGrowthLevelName: state => renterGrowthLevelName(state.renterGrowthLevel),
|
||||||
|
isQualityOwner: state => state.qualityOwnerStatus === 'active',
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
async login(phone: string, code: string) {
|
async login(phone: string, code: string) {
|
||||||
@@ -104,6 +106,7 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.realnameStatus = 'unknown'
|
this.realnameStatus = 'unknown'
|
||||||
this.renterGrowthPoints = 0
|
this.renterGrowthPoints = 0
|
||||||
this.renterGrowthLevel = 'normal'
|
this.renterGrowthLevel = 'normal'
|
||||||
|
this.qualityOwnerStatus = 'none'
|
||||||
clearAuthStorage('user')
|
clearAuthStorage('user')
|
||||||
},
|
},
|
||||||
syncFromStorage() {
|
syncFromStorage() {
|
||||||
@@ -116,6 +119,7 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.realnameStatus = localStorage.getItem('realname_status') || 'unknown'
|
this.realnameStatus = localStorage.getItem('realname_status') || 'unknown'
|
||||||
this.renterGrowthPoints = Number(localStorage.getItem('renter_growth_points') || 0)
|
this.renterGrowthPoints = Number(localStorage.getItem('renter_growth_points') || 0)
|
||||||
this.renterGrowthLevel = localStorage.getItem('renter_growth_level') || 'normal'
|
this.renterGrowthLevel = localStorage.getItem('renter_growth_level') || 'normal'
|
||||||
|
this.qualityOwnerStatus = localStorage.getItem('quality_owner_status') || 'none'
|
||||||
},
|
},
|
||||||
applySession(user: AuthUser, accessToken: string, refreshToken: string) {
|
applySession(user: AuthUser, accessToken: string, refreshToken: string) {
|
||||||
this.token = accessToken
|
this.token = accessToken
|
||||||
@@ -143,6 +147,8 @@ export const useSessionStore = defineStore('session', {
|
|||||||
localStorage.setItem('renter_growth_points', String(this.renterGrowthPoints))
|
localStorage.setItem('renter_growth_points', String(this.renterGrowthPoints))
|
||||||
this.renterGrowthLevel = user.renter_growth_level || 'normal'
|
this.renterGrowthLevel = user.renter_growth_level || 'normal'
|
||||||
localStorage.setItem('renter_growth_level', this.renterGrowthLevel)
|
localStorage.setItem('renter_growth_level', this.renterGrowthLevel)
|
||||||
|
this.qualityOwnerStatus = user.quality_owner_status || 'none'
|
||||||
|
localStorage.setItem('quality_owner_status', this.qualityOwnerStatus)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user