feat(quality-owner): 增加优质号主、极品专区与无忧租号保险
- 支持保证金余额支付、在线支付开通及后台手动设置审计 - 新增极品专区发布权限、赔付方案与保险结算费率快照 - 优化首页、商品卡和个人中心的优质号主身份展示
This commit is contained in:
@@ -43,6 +43,9 @@ type RentalListing struct {
|
||||
ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"`
|
||||
DistributionSiteID *uint64 `gorm:"column:distribution_site_id;index" json:"distribution_site_id,omitempty"`
|
||||
SourceChannel string `gorm:"size:64;not null;default:''" json:"-"`
|
||||
IsPremium bool `gorm:"not null;default:false" json:"is_premium"`
|
||||
CompensationPlan string `gorm:"size:16;not null;default:'normal'" json:"compensation_plan"`
|
||||
InsuranceFeeBps int `gorm:"not null;default:0" json:"insurance_fee_bps"`
|
||||
ReviewReason string `gorm:"size:255;not null;default:''" json:"review_reason"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -53,6 +53,10 @@ type RentalOrder struct {
|
||||
DistributionSiteID *uint64 `gorm:"index" json:"distribution_site_id,omitempty"`
|
||||
DistributionMode string `gorm:"size:32;not null;default:'';index" json:"distribution_mode"`
|
||||
DistributionRuleVersionID *uint64 `gorm:"index" json:"distribution_rule_version_id,omitempty"`
|
||||
IsPremium bool `gorm:"not null;default:false" json:"is_premium"`
|
||||
CompensationPlan string `gorm:"size:16;not null;default:'normal'" json:"compensation_plan"`
|
||||
InsuranceFeeBps int `gorm:"not null;default:0" json:"insurance_fee_bps"`
|
||||
InsuranceFeeCent int64 `gorm:"not null;default:0" json:"insurance_fee_cent"`
|
||||
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
|
||||
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
|
||||
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
|
||||
|
||||
@@ -17,6 +17,8 @@ type OrderCheckout struct {
|
||||
RentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
OwnerRentAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
PlatformFeeCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
InsuranceFeeBps int `gorm:"not null;default:0" json:"-"`
|
||||
InsuranceFeeCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
DepositAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
PureCoinAmountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
PureCoinDiscountCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
|
||||
@@ -11,6 +11,7 @@ type PaymentOrder struct {
|
||||
PaymentNo string `gorm:"size:64;not null;uniqueIndex" json:"payment_no"`
|
||||
OrderID uint64 `gorm:"not null;index" json:"order_id"`
|
||||
OrderNo string `gorm:"size:64;not null;index" json:"order_no"`
|
||||
BizID uint64 `gorm:"not null;default:0;index:idx_payment_orders_user_biz,priority:3" json:"biz_id"`
|
||||
UserID uint64 `gorm:"not null;index" json:"user_id"`
|
||||
PaymentConfigID uint64 `gorm:"not null;default:0;index" json:"payment_config_id"`
|
||||
Provider string `gorm:"size:32;not null" json:"provider"`
|
||||
|
||||
@@ -3,18 +3,22 @@ package model
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"`
|
||||
PasswordHash string `gorm:"size:255;not null;default:''" json:"-"`
|
||||
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
||||
AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"`
|
||||
RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"`
|
||||
RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"`
|
||||
CreditScore int `gorm:"not null;default:100" json:"credit_score"`
|
||||
DepositFreeQuotaCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"`
|
||||
RenterGrowthLevel string `gorm:"size:32;not null;default:'normal';index:idx_users_renter_growth_level,priority:1" json:"renter_growth_level"`
|
||||
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
Phone string `gorm:"size:32;not null;uniqueIndex" json:"phone"`
|
||||
PasswordHash string `gorm:"size:255;not null;default:''" json:"-"`
|
||||
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
||||
AvatarURL string `gorm:"size:512;not null;default:''" json:"avatar_url"`
|
||||
RealnameStatus string `gorm:"size:32;not null;default:'unverified'" json:"realname_status"`
|
||||
RiskStatus string `gorm:"size:32;not null;default:'normal'" json:"risk_status"`
|
||||
CreditScore int `gorm:"not null;default:100" json:"credit_score"`
|
||||
DepositFreeQuotaCent int64 `gorm:"not null;default:0" json:"-"`
|
||||
RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"`
|
||||
RenterGrowthLevel string `gorm:"size:32;not null;default:'normal';index:idx_users_renter_growth_level,priority:1" json:"renter_growth_level"`
|
||||
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
||||
QualityOwnerStatus string `gorm:"size:16;not null;default:'none';index:idx_users_quality_owner_status" json:"quality_owner_status"`
|
||||
QualityOwnerDepositCent int64 `gorm:"not null;default:0" json:"quality_owner_deposit_cent"`
|
||||
QualityOwnerDepositSource string `gorm:"size:16;not null;default:''" json:"quality_owner_deposit_source"`
|
||||
QualityOwnerActivatedAt *time.Time `json:"quality_owner_activated_at"`
|
||||
// 会话版本:递增后该用户已签发的全部令牌立即失效。
|
||||
// 默认值对齐迁移 000052 的 users.token_version DEFAULT 0。
|
||||
TokenVersion int64 `gorm:"not null;default:0" json:"-"`
|
||||
|
||||
@@ -20,6 +20,10 @@ type UserDTO struct {
|
||||
RenterGrowthLevelName string `json:"renter_growth_level_name"`
|
||||
AvailableBalanceCent int64 `json:"available_balance_cent"`
|
||||
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
|
||||
QualityOwnerStatus string `json:"quality_owner_status"`
|
||||
QualityOwnerDepositCent int64 `json:"quality_owner_deposit_cent"`
|
||||
QualityOwnerDepositSource string `json:"quality_owner_deposit_source"`
|
||||
QualityOwnerActivatedAt *time.Time `json:"quality_owner_activated_at"`
|
||||
Status string `json:"status"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
ListingCount int64 `json:"listing_count"`
|
||||
@@ -65,6 +69,7 @@ type ListQuery struct {
|
||||
Keyword string
|
||||
Status string
|
||||
HasDepositFreeQuota bool
|
||||
QualityOwnerOnly bool
|
||||
}
|
||||
|
||||
type PaginatedResult struct {
|
||||
|
||||
@@ -41,6 +41,7 @@ func (h *Handler) List(c *gin.Context) {
|
||||
Keyword: strings.TrimSpace(c.Query("keyword")),
|
||||
Status: strings.TrimSpace(c.Query("status")),
|
||||
HasDepositFreeQuota: parseBoolQuery(c.Query("has_deposit_free_quota")),
|
||||
QualityOwnerOnly: parseBoolQuery(c.Query("quality_owner_only")),
|
||||
}
|
||||
result, err := h.service.List(c.Request.Context(), page, pageSize, query)
|
||||
if err != nil {
|
||||
|
||||
@@ -106,6 +106,9 @@ func applyUserFilter(tx *gorm.DB, query ListQuery, growthConfig rentergrowth.Con
|
||||
tx = tx.Where("u.deposit_free_quota_cent > 0")
|
||||
}
|
||||
}
|
||||
if query.QualityOwnerOnly {
|
||||
tx = tx.Where("u.quality_owner_status = ?", "active")
|
||||
}
|
||||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||
if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) {
|
||||
// 11 位手机号:命中 users(phone) 唯一索引
|
||||
@@ -563,6 +566,10 @@ func (row userRow) toDTO(growthConfig rentergrowth.Config) UserDTO {
|
||||
RenterGrowthLevelName: level.Name,
|
||||
AvailableBalanceCent: row.AvailableBalanceCent,
|
||||
FrozenBalanceCent: row.FrozenBalanceCent,
|
||||
QualityOwnerStatus: row.QualityOwnerStatus,
|
||||
QualityOwnerDepositCent: row.QualityOwnerDepositCent,
|
||||
QualityOwnerDepositSource: row.QualityOwnerDepositSource,
|
||||
QualityOwnerActivatedAt: row.QualityOwnerActivatedAt,
|
||||
Status: row.Status,
|
||||
OrderCount: row.OrderCount,
|
||||
ListingCount: row.ListingCount,
|
||||
|
||||
@@ -15,6 +15,7 @@ type ListingDTO struct {
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||
OwnerNickname string `json:"owner_nickname,omitempty"`
|
||||
OwnerIsQualityOwner bool `json:"owner_is_quality_owner"`
|
||||
SourceChannel string `json:"source_channel,omitempty"`
|
||||
IsExternalUpload bool `json:"is_external_upload"`
|
||||
Title string `json:"title"`
|
||||
@@ -41,6 +42,9 @@ type ListingDTO struct {
|
||||
DistributionSiteID *uint64 `json:"distribution_site_id,omitempty"`
|
||||
DistributionSiteCode string `json:"distribution_site_code,omitempty"`
|
||||
DistributionSiteName string `json:"distribution_site_name,omitempty"`
|
||||
IsPremium bool `json:"is_premium"`
|
||||
CompensationPlan string `json:"compensation_plan"`
|
||||
InsuranceFeeBps int `json:"insurance_fee_bps"`
|
||||
ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -57,6 +61,9 @@ type CreateRequest struct {
|
||||
ScreenshotURLS []string `json:"screenshot_urls"`
|
||||
PriceCent int64 `json:"price_cent"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
IsPremium bool `json:"is_premium"`
|
||||
CompensationPlan string `json:"compensation_plan" binding:"omitempty,oneof=normal insurance"`
|
||||
InsuranceFeeBps int `json:"-"` // 服务端按配置和用户资格生成,不接受前端指定。
|
||||
|
||||
AgreedVirtualAssetSale bool `json:"agreed_virtual_asset_sale"`
|
||||
AgreedSellerAgreement bool `json:"agreed_seller_agreement"`
|
||||
|
||||
@@ -57,6 +57,8 @@ func writeListingError(c *gin.Context, err error) {
|
||||
response.BadRequest(c, "发布比例必须在参考比例至参考比例加 10 之间")
|
||||
case errors.Is(err, ErrDistributionSiteInvalid):
|
||||
response.BadRequest(c, "分销站点无效或已停用")
|
||||
case errors.Is(err, ErrPremiumOwnerRequired):
|
||||
response.Error(c, http.StatusForbidden, "quality_owner_required", "仅优质号主可以发布到极品专区")
|
||||
case isUploadValidationError(err):
|
||||
response.BadRequest(c, err.Error())
|
||||
case isFireLevelTooLow(err):
|
||||
|
||||
@@ -76,6 +76,9 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque
|
||||
OwnerID: ownerID,
|
||||
PriceCent: priceCent,
|
||||
DepositAmountCent: req.DepositAmountCent,
|
||||
IsPremium: req.IsPremium,
|
||||
CompensationPlan: req.CompensationPlan,
|
||||
InsuranceFeeBps: req.InsuranceFeeBps,
|
||||
Status: listingStatus,
|
||||
ReviewStatus: reviewStatus,
|
||||
PublishedAt: publishedAt,
|
||||
@@ -289,6 +292,9 @@ func (r *Repository) Update(ctx context.Context, ownerID uint64, listingID uint6
|
||||
|
||||
listing.PriceCent = normalizedListingPriceCent(req)
|
||||
listing.DepositAmountCent = req.DepositAmountCent
|
||||
listing.IsPremium = req.IsPremium
|
||||
listing.CompensationPlan = req.CompensationPlan
|
||||
listing.InsuranceFeeBps = req.InsuranceFeeBps
|
||||
fromStatus := listing.Status
|
||||
listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired)
|
||||
listing.Status = listingStatus
|
||||
|
||||
@@ -18,6 +18,7 @@ type listingRow struct {
|
||||
Title string
|
||||
OwnerPhone string
|
||||
OwnerNickname string
|
||||
OwnerIsQualityOwner bool `gorm:"column:owner_is_quality_owner"`
|
||||
DisplaySourceChannel string `gorm:"column:display_source_channel"`
|
||||
IsExternalUpload bool `gorm:"column:is_external_upload"`
|
||||
DistributionSiteCode string `gorm:"column:distribution_site_code"`
|
||||
@@ -171,6 +172,7 @@ func (row listingRow) toDTO() ListingDTO {
|
||||
OwnerID: row.OwnerID,
|
||||
OwnerPhone: row.OwnerPhone,
|
||||
OwnerNickname: row.OwnerNickname,
|
||||
OwnerIsQualityOwner: row.OwnerIsQualityOwner,
|
||||
SourceChannel: row.DisplaySourceChannel,
|
||||
IsExternalUpload: row.IsExternalUpload,
|
||||
Title: row.Title,
|
||||
@@ -195,6 +197,9 @@ func (row listingRow) toDTO() ListingDTO {
|
||||
DistributionSiteID: row.DistributionSiteID,
|
||||
DistributionSiteCode: row.DistributionSiteCode,
|
||||
DistributionSiteName: row.DistributionSiteName,
|
||||
IsPremium: row.IsPremium,
|
||||
CompensationPlan: normalizedCompensationPlan(row.CompensationPlan),
|
||||
InsuranceFeeBps: row.InsuranceFeeBps,
|
||||
ReviewReason: reviewReason,
|
||||
PublishedAt: row.PublishedAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
@@ -231,6 +236,9 @@ func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO {
|
||||
SettlementMode: listing.SettlementMode,
|
||||
ManagedAdminID: listing.ManagedAdminID,
|
||||
DistributionSiteID: listing.DistributionSiteID,
|
||||
IsPremium: listing.IsPremium,
|
||||
CompensationPlan: normalizedCompensationPlan(listing.CompensationPlan),
|
||||
InsuranceFeeBps: listing.InsuranceFeeBps,
|
||||
ReviewReason: reviewReason,
|
||||
PublishedAt: listing.PublishedAt,
|
||||
CreatedAt: listing.CreatedAt,
|
||||
@@ -238,6 +246,13 @@ func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedCompensationPlan(plan string) string {
|
||||
if plan == CompensationPlanInsurance {
|
||||
return CompensationPlanInsurance
|
||||
}
|
||||
return CompensationPlanNormal
|
||||
}
|
||||
|
||||
func normalizedReviewState(status string, reviewStatus string, reviewReason string) (string, string) {
|
||||
if status == "offline" {
|
||||
return "none", reviewReason
|
||||
|
||||
@@ -185,6 +185,8 @@ func matchesPublicZone(item ListingDTO, zone string) bool {
|
||||
return strings.Contains(item.LoginPlatform, "账密") || strings.Contains(item.LoginPlatform, "账号密码")
|
||||
case "highCoin":
|
||||
return coinMFromListing(item) >= 100
|
||||
case "premium":
|
||||
return item.IsPremium
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -198,9 +200,10 @@ func publicZoneCounts(items []ListingDTO) map[string]int64 {
|
||||
"night": 0,
|
||||
"password": 0,
|
||||
"highCoin": 0,
|
||||
"premium": 0,
|
||||
}
|
||||
for _, item := range items {
|
||||
for _, zone := range []string{"sale", "gift", "night", "password", "highCoin"} {
|
||||
for _, zone := range []string{"sale", "gift", "night", "password", "highCoin", "premium"} {
|
||||
if matchesPublicZone(item, zone) {
|
||||
counts[zone]++
|
||||
}
|
||||
|
||||
@@ -21,6 +21,17 @@ func TestFilterPublicListingsByRatio(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesPublicZonePremium(t *testing.T) {
|
||||
premium := ListingDTO{ID: 1, IsPremium: true}
|
||||
ordinary := ListingDTO{ID: 2}
|
||||
if !matchesPublicZone(premium, "premium") {
|
||||
t.Fatal("premium listing should match premium zone")
|
||||
}
|
||||
if matchesPublicZone(ordinary, "premium") {
|
||||
t.Fatal("ordinary listing should not match premium zone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPublicListingsRegionMultiSelectRequiresAll(t *testing.T) {
|
||||
items := []ListingDTO{
|
||||
{
|
||||
|
||||
@@ -185,7 +185,7 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int
|
||||
|
||||
var rows []publicZoneRow
|
||||
err := r.db.WithContext(ctx).Table("rental_listings AS l").
|
||||
Select("a.login_platform, a.haf_coin_amount, a.asset_summary").
|
||||
Select("a.login_platform, a.haf_coin_amount, a.asset_summary, l.is_premium").
|
||||
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
||||
Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false).
|
||||
Scan(&rows).Error
|
||||
@@ -199,6 +199,7 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int
|
||||
"night": 0,
|
||||
"password": 0,
|
||||
"highCoin": 0,
|
||||
"premium": 0,
|
||||
}
|
||||
for _, row := range rows {
|
||||
summary := decodeAssetSummary(row.AssetSummary)
|
||||
@@ -217,6 +218,9 @@ func (r *Repository) publicZoneCountsCached(ctx context.Context) (map[string]int
|
||||
if float64(row.HafCoinAmount)/1000000 >= 100 {
|
||||
counts["highCoin"]++
|
||||
}
|
||||
if row.IsPremium {
|
||||
counts["premium"]++
|
||||
}
|
||||
}
|
||||
r.publicZoneCounts = publicZoneCountCache{
|
||||
Counts: counts,
|
||||
@@ -319,4 +323,5 @@ type publicZoneRow struct {
|
||||
LoginPlatform string
|
||||
HafCoinAmount int64
|
||||
AssetSummary datatypes.JSON `gorm:"column:asset_summary"`
|
||||
IsPremium bool `gorm:"column:is_premium"`
|
||||
}
|
||||
|
||||
@@ -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").
|
||||
Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level,
|
||||
a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname,
|
||||
COALESCE(u.quality_owner_status = 'active', 0) AS owner_is_quality_owner,
|
||||
CASE WHEN lu.id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
|
||||
COALESCE(src_site.site_code, '') AS distribution_site_code,
|
||||
COALESCE(src_site.site_name, '') AS distribution_site_name,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -57,6 +58,20 @@ func (r *Repository) syncPublishedShelf(tx *gorm.DB, listing *model.RentalListin
|
||||
return r.shelfSyncer.EnsureListingShelvedTx(tx, listing.ID, listing.AccountID)
|
||||
}
|
||||
|
||||
func (r *Repository) IsQualityOwner(ctx context.Context, userID uint64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, ErrDependencyUnavailable
|
||||
}
|
||||
var status string
|
||||
err := r.db.WithContext(ctx).Model(&model.User{}).
|
||||
Where("id = ?", userID).
|
||||
Pluck("quality_owner_status", &status).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return status == "active", nil
|
||||
}
|
||||
|
||||
type publicZoneCountCache struct {
|
||||
Counts map[string]int64
|
||||
ExpiresAt time.Time
|
||||
|
||||
@@ -61,6 +61,9 @@ func (r *Repository) TransferOwner(ctx context.Context, adminID uint64, listingI
|
||||
}
|
||||
return err
|
||||
}
|
||||
if listing.IsPremium && target.QualityOwnerStatus != "active" {
|
||||
return ErrPremiumOwnerRequired
|
||||
}
|
||||
|
||||
beforeOwnerID := listing.OwnerID
|
||||
listing.OwnerID = target.ID
|
||||
|
||||
@@ -33,6 +33,7 @@ var (
|
||||
ErrExternalAdjustment = errors.New("invalid external listing adjustment")
|
||||
ErrSaleRatioOutOfRange = errors.New("sale ratio out of range")
|
||||
ErrDistributionSiteInvalid = errors.New("invalid distribution site")
|
||||
ErrPremiumOwnerRequired = errors.New("premium listing requires quality owner")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -52,6 +53,8 @@ const (
|
||||
defaultPublishCooldownMinutes = 5
|
||||
maxExternalUploadItems = 10
|
||||
defaultUploadScreenshot = "/api/listings/default-upload-screenshot?v=webp1"
|
||||
CompensationPlanNormal = "normal"
|
||||
CompensationPlanInsurance = "insurance"
|
||||
)
|
||||
|
||||
type PublishCooldownError struct {
|
||||
|
||||
@@ -3,6 +3,8 @@ package listing
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/modules/qualityowner"
|
||||
)
|
||||
|
||||
func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest, siteCode ...string) (*ListingDTO, error) {
|
||||
@@ -30,6 +32,9 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest,
|
||||
if err := validateRequiredOnlineTime(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cooldown, err := s.publishCooldown(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -63,6 +68,9 @@ func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req Upd
|
||||
if err := validateRequiredOnlineTime(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.applyPublishEntitlements(ctx, ownerID, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reviewRequired, err := s.reviewRequired(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -70,6 +78,45 @@ func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req Upd
|
||||
return s.repo.Update(ctx, ownerID, id, req, reviewRequired)
|
||||
}
|
||||
|
||||
func (s *Service) applyPublishEntitlements(ctx context.Context, ownerID uint64, req *CreateRequest) error {
|
||||
req.CompensationPlan = strings.TrimSpace(req.CompensationPlan)
|
||||
if req.CompensationPlan == "" {
|
||||
req.CompensationPlan = CompensationPlanNormal
|
||||
}
|
||||
if req.CompensationPlan != CompensationPlanNormal && req.CompensationPlan != CompensationPlanInsurance {
|
||||
return ErrInvalidInput
|
||||
}
|
||||
|
||||
isQualityOwner, err := s.repo.IsQualityOwner(ctx, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.IsPremium && !isQualityOwner {
|
||||
return ErrPremiumOwnerRequired
|
||||
}
|
||||
|
||||
req.InsuranceFeeBps = 0
|
||||
if req.CompensationPlan != CompensationPlanInsurance {
|
||||
return nil
|
||||
}
|
||||
rules := qualityowner.DefaultRules()
|
||||
if s.config != nil {
|
||||
value, err := s.config.FindValue(ctx, qualityowner.ConfigKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules, err = qualityowner.ParseConfigValue(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
req.InsuranceFeeBps = rules.InsuranceFeeBps
|
||||
if req.IsPremium && isQualityOwner {
|
||||
req.InsuranceFeeBps = rules.PremiumInsuranceFeeBps
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) SubmitReview(ctx context.Context, ownerID uint64, id uint64) (*ListingDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
|
||||
@@ -46,6 +46,7 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
||||
order.ActualPureCoinAmountCent = settlement.PureCoinAmountCent
|
||||
order.ActualPureCoinDiscountCent = settlement.PureCoinDiscountCent
|
||||
order.ActualExtraItemAmountCent = settlement.ExtraItemAmountCent
|
||||
order.InsuranceFeeCent = settlement.InsuranceFeeCent
|
||||
if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -140,6 +141,8 @@ func applyCheckoutSettlement(checkout *model.OrderCheckout, settlement checkoutS
|
||||
checkout.RentAmountCent = settlement.ActualRentAmountCent
|
||||
checkout.OwnerRentAmountCent = settlement.OwnerRentIncomeCent
|
||||
checkout.PlatformFeeCent = settlement.PlatformFeeCent
|
||||
checkout.InsuranceFeeBps = settlement.InsuranceFeeBps
|
||||
checkout.InsuranceFeeCent = settlement.InsuranceFeeCent
|
||||
checkout.PureCoinAmountCent = settlement.PureCoinAmountCent
|
||||
checkout.PureCoinDiscountCent = settlement.PureCoinDiscountCent
|
||||
checkout.PureCoinPayableCent = settlement.PureCoinPayableCent
|
||||
|
||||
@@ -58,6 +58,10 @@ type OrderDTO struct {
|
||||
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
|
||||
AccountSource string `json:"account_source"`
|
||||
SourceChannel string `json:"source_channel,omitempty"`
|
||||
IsPremium bool `json:"is_premium"`
|
||||
CompensationPlan string `json:"compensation_plan"`
|
||||
InsuranceFeeBps int `json:"insurance_fee_bps"`
|
||||
InsuranceFeeCent int64 `json:"insurance_fee_cent"`
|
||||
Status string `json:"status"`
|
||||
HandoffStatus string `json:"handoff_status"`
|
||||
HandoffMode string `json:"handoff_mode"`
|
||||
@@ -330,6 +334,8 @@ type CheckoutDTO struct {
|
||||
RentAmountCent *int64 `json:"rent_amount_cent,omitempty"`
|
||||
OwnerRentAmountCent *int64 `json:"owner_rent_amount_cent,omitempty"`
|
||||
PlatformFeeCent *int64 `json:"platform_fee_cent,omitempty"`
|
||||
InsuranceFeeBps int `json:"insurance_fee_bps"`
|
||||
InsuranceFeeCent int64 `json:"insurance_fee_cent"`
|
||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||
PureCoinAmountCent int64 `json:"pure_coin_amount_cent"`
|
||||
PureCoinDiscountCent int64 `json:"pure_coin_discount_cent"`
|
||||
|
||||
@@ -151,6 +151,9 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
||||
AccountSnapshot: snapshot,
|
||||
AccountSource: accountSource,
|
||||
SourceChannel: sourceChannel,
|
||||
IsPremium: listing.IsPremium,
|
||||
CompensationPlan: normalizedCompensationPlan(listing.CompensationPlan),
|
||||
InsuranceFeeBps: listing.InsuranceFeeBps,
|
||||
Status: orderStatusPendingPayment,
|
||||
HandoffStatus: handoffStatusNone,
|
||||
HandoffMode: listingHandoffMode(listing),
|
||||
@@ -194,6 +197,13 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
||||
return r.FindForUser(ctx, renterID, createdID, req.DistributionSiteCode)
|
||||
}
|
||||
|
||||
func normalizedCompensationPlan(plan string) string {
|
||||
if plan == "insurance" {
|
||||
return "insurance"
|
||||
}
|
||||
return "normal"
|
||||
}
|
||||
|
||||
func loadShelfRatioTx(tx *gorm.DB, siteCode string, listingID uint64) (float64, uint64, error) {
|
||||
siteCode = strings.TrimSpace(siteCode)
|
||||
if siteCode == "" || listingID == 0 {
|
||||
|
||||
@@ -115,6 +115,10 @@ func (row orderRow) toAdminDTO() OrderDTO {
|
||||
AccountSnapshot: row.AccountSnapshot,
|
||||
AccountSource: effectiveAccountSource(row.RentalOrder),
|
||||
SourceChannel: row.SourceChannel,
|
||||
IsPremium: row.IsPremium,
|
||||
CompensationPlan: normalizedCompensationPlan(row.CompensationPlan),
|
||||
InsuranceFeeBps: row.InsuranceFeeBps,
|
||||
InsuranceFeeCent: row.InsuranceFeeCent,
|
||||
Status: row.Status,
|
||||
HandoffStatus: row.HandoffStatus,
|
||||
HandoffMode: effectiveHandoffMode(row.RentalOrder),
|
||||
@@ -312,6 +316,8 @@ func toCheckoutAdminDTO(checkout model.OrderCheckout) CheckoutDTO {
|
||||
RentAmountCent: &rentAmountCent,
|
||||
OwnerRentAmountCent: &ownerRentAmountCent,
|
||||
PlatformFeeCent: &platformFeeCent,
|
||||
InsuranceFeeBps: checkout.InsuranceFeeBps,
|
||||
InsuranceFeeCent: checkout.InsuranceFeeCent,
|
||||
DepositAmountCent: checkout.DepositAmountCent,
|
||||
PureCoinAmountCent: checkout.PureCoinAmountCent,
|
||||
PureCoinDiscountCent: checkout.PureCoinDiscountCent,
|
||||
|
||||
@@ -32,6 +32,8 @@ type checkoutSettlement struct {
|
||||
DepositRefundCent int64
|
||||
RenterRefundCent int64
|
||||
PlatformFeeCent int64
|
||||
InsuranceFeeBps int
|
||||
InsuranceFeeCent int64
|
||||
ActualRentAmountCent int64
|
||||
PureCoinAmountCent int64
|
||||
PureCoinDiscountCent int64
|
||||
@@ -59,6 +61,8 @@ type ActualCheckoutSettlement struct {
|
||||
DepositRefundCent int64
|
||||
RenterRefundCent int64
|
||||
PlatformFeeCent int64
|
||||
InsuranceFeeBps int
|
||||
InsuranceFeeCent int64
|
||||
ActualRentAmountCent int64
|
||||
PureCoinAmountCent int64
|
||||
PureCoinDiscountCent int64
|
||||
@@ -94,6 +98,8 @@ func toActualCheckoutSettlement(settlement checkoutSettlement) ActualCheckoutSet
|
||||
DepositRefundCent: settlement.DepositRefundCent,
|
||||
RenterRefundCent: settlement.RenterRefundCent,
|
||||
PlatformFeeCent: settlement.PlatformFeeCent,
|
||||
InsuranceFeeBps: settlement.InsuranceFeeBps,
|
||||
InsuranceFeeCent: settlement.InsuranceFeeCent,
|
||||
ActualRentAmountCent: settlement.ActualRentAmountCent,
|
||||
PureCoinAmountCent: settlement.PureCoinAmountCent,
|
||||
PureCoinDiscountCent: settlement.PureCoinDiscountCent,
|
||||
@@ -273,6 +279,8 @@ func buildCheckout(order model.RentalOrder, initiatedBy uint64, status string, c
|
||||
RentAmountCent: settlement.ActualRentAmountCent,
|
||||
OwnerRentAmountCent: settlement.OwnerRentIncomeCent,
|
||||
PlatformFeeCent: settlement.PlatformFeeCent,
|
||||
InsuranceFeeBps: settlement.InsuranceFeeBps,
|
||||
InsuranceFeeCent: settlement.InsuranceFeeCent,
|
||||
DepositAmountCent: order.DepositAmountCent,
|
||||
PureCoinAmountCent: settlement.PureCoinAmountCent,
|
||||
PureCoinDiscountCent: settlement.PureCoinDiscountCent,
|
||||
@@ -398,6 +406,8 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
||||
// 可完结时租客实际承担的租金向金额;有 shortfall 时仍展示完整应付便于协商
|
||||
actualRentAmountCent := actualBuyerRentCent
|
||||
ownerRentIncomeCent := actualOwnerRentCent
|
||||
insuranceFeeCent := calculateInsuranceFeeCent(ownerRentIncomeCent, order.InsuranceFeeBps)
|
||||
ownerRentIncomeCent = maxCent(ownerRentIncomeCent-insuranceFeeCent, 0)
|
||||
platformFeeCent := maxCent(actualRentAmountCent-ownerRentIncomeCent, 0)
|
||||
depositCompensationCent := damageAppliedCent
|
||||
|
||||
@@ -409,6 +419,8 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
||||
DepositRefundCent: depositRefundCent,
|
||||
RenterRefundCent: rentRefundCent + depositRefundCent,
|
||||
PlatformFeeCent: platformFeeCent,
|
||||
InsuranceFeeBps: order.InsuranceFeeBps,
|
||||
InsuranceFeeCent: insuranceFeeCent,
|
||||
ActualRentAmountCent: actualRentAmountCent,
|
||||
PureCoinAmountCent: usedBuyerCoinPriceCent,
|
||||
PureCoinDiscountCent: pureCoinDiscountCent,
|
||||
@@ -420,6 +432,14 @@ func calculateCheckoutSettlement(order model.RentalOrder, consumableAmountCent i
|
||||
}
|
||||
}
|
||||
|
||||
func calculateInsuranceFeeCent(ownerRentIncomeCent int64, feeBps int) int64 {
|
||||
if ownerRentIncomeCent <= 0 || feeBps <= 0 {
|
||||
return 0
|
||||
}
|
||||
feeCent := (ownerRentIncomeCent*int64(feeBps) + 5_000) / 10_000
|
||||
return minCent(feeCent, ownerRentIncomeCent)
|
||||
}
|
||||
|
||||
func orderPureCoinOriginalAmountCent(order model.RentalOrder) int64 {
|
||||
originalRentCent := effectiveRentOriginalAmountCent(order)
|
||||
if order.PureCoinOriginalAmountCent > 0 {
|
||||
|
||||
@@ -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 notifyFn func(uint64)
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if payment.OrderID != 0 {
|
||||
switch payment.BizType {
|
||||
case "mohong_pay":
|
||||
if r.mohongRepo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
convID, err := r.mohongRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newConvID = convID
|
||||
notifyFn = r.mohongRepo.NotifyNewConversation
|
||||
default:
|
||||
if r.orderRepo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
convID, err := r.orderRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newConvID = convID
|
||||
notifyFn = r.orderRepo.NotifyNewConversation
|
||||
if err := distribution.SnapshotPaidOrderTx(tx, payment.OrderID, payment.AmountCent); err != nil {
|
||||
return err
|
||||
switch payment.BizType {
|
||||
case qualityOwnerPayBizType:
|
||||
if r.qualityOwnerRepo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
if err := r.qualityOwnerRepo.ConfirmQualityOwnerDepositPaidTx(
|
||||
tx,
|
||||
payment.UserID,
|
||||
payment.AmountCent,
|
||||
payment.PaymentNo,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
if payment.OrderID != 0 {
|
||||
switch payment.BizType {
|
||||
case "mohong_pay":
|
||||
if r.mohongRepo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
convID, err := r.mohongRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newConvID = convID
|
||||
notifyFn = r.mohongRepo.NotifyNewConversation
|
||||
default:
|
||||
if r.orderRepo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
convID, err := r.orderRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newConvID = convID
|
||||
notifyFn = r.orderRepo.NotifyNewConversation
|
||||
if err := distribution.SnapshotPaidOrderTx(tx, payment.OrderID, payment.AmountCent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type PaymentDTO struct {
|
||||
PaymentNo string `json:"payment_no"`
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
BizID uint64 `json:"biz_id"`
|
||||
PaymentConfigID uint64 `json:"payment_config_id"`
|
||||
Provider string `json:"provider"`
|
||||
ThirdOrderID string `json:"third_order_id"`
|
||||
@@ -28,6 +29,7 @@ type PaymentDTO struct {
|
||||
PayWay string `json:"pay_way"`
|
||||
JSPayFlag string `json:"jspay_flag"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
BizType string `json:"biz_type"`
|
||||
Status string `json:"status"`
|
||||
TDCode string `json:"td_code,omitempty"`
|
||||
JSPayURL string `json:"jspay_url,omitempty"`
|
||||
@@ -80,6 +82,7 @@ type AdminPaymentDTO struct {
|
||||
PaymentNo string `json:"payment_no"`
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
BizID uint64 `json:"biz_id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
PaymentConfigID uint64 `json:"payment_config_id"`
|
||||
UserPhone string `json:"user_phone"`
|
||||
|
||||
@@ -111,6 +111,22 @@ func (h *Handler) StartMohong(c *gin.Context) {
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) StartQualityOwner(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
var req StartPaymentRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
item, err := h.service.StartQualityOwner(c.Request.Context(), userID, req, c.ClientIP())
|
||||
if err != nil {
|
||||
writePaymentError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) Query(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
@@ -147,6 +163,20 @@ func (h *Handler) QueryMohong(c *gin.Context) {
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) QueryQualityOwner(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
item, err := h.service.QueryQualityOwner(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
writePaymentError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) AdminRefundMohong(c *gin.Context) {
|
||||
orderID, ok := parseID(c)
|
||||
if !ok {
|
||||
|
||||
@@ -20,6 +20,11 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.startPreparedPayment(ctx, payment, "租号订单 "+orderRow.OrderNo, orderRow.OrderNo, clientIP)
|
||||
}
|
||||
|
||||
// startPreparedPayment 对已经创建或复用的支付单执行渠道下单、收银台复用或模拟支付。
|
||||
func (r *Repository) startPreparedPayment(ctx context.Context, payment *model.PaymentOrder, body string, attach string, clientIP string) (*PaymentDTO, error) {
|
||||
runtimeConfig, err := r.runtimeConfigForPayment(ctx, payment)
|
||||
if err != nil {
|
||||
return nil, ErrPaymentUnavailable
|
||||
@@ -64,8 +69,8 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r
|
||||
NotifyURL: runtimeConfig.NotifyURL,
|
||||
JumpURL: runtimeConfig.JumpURL,
|
||||
ClientIP: clientIP,
|
||||
Body: "租号订单 " + orderRow.OrderNo,
|
||||
Attach: orderRow.OrderNo,
|
||||
Body: body,
|
||||
Attach: attach,
|
||||
})
|
||||
if err != nil {
|
||||
_ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error())
|
||||
|
||||
@@ -19,6 +19,7 @@ func toDTO(payment model.PaymentOrder) PaymentDTO {
|
||||
PaymentNo: payment.PaymentNo,
|
||||
OrderID: payment.OrderID,
|
||||
OrderNo: payment.OrderNo,
|
||||
BizID: payment.BizID,
|
||||
PaymentConfigID: payment.PaymentConfigID,
|
||||
Provider: payment.Provider,
|
||||
ThirdOrderID: payment.ThirdOrderID,
|
||||
@@ -26,6 +27,7 @@ func toDTO(payment model.PaymentOrder) PaymentDTO {
|
||||
PayWay: payment.PayWay,
|
||||
JSPayFlag: payment.JSPayFlag,
|
||||
AmountCent: payment.AmountCent,
|
||||
BizType: payment.BizType,
|
||||
Status: payment.Status,
|
||||
TDCode: payment.TDCode,
|
||||
JSPayURL: payment.JSPayURL,
|
||||
|
||||
@@ -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,
|
||||
OrderID: row.OrderID,
|
||||
OrderNo: row.OrderNo,
|
||||
BizID: row.BizID,
|
||||
UserID: row.UserID,
|
||||
PaymentConfigID: row.PaymentConfigID,
|
||||
UserPhone: row.UserPhone,
|
||||
|
||||
@@ -19,12 +19,19 @@ type MohongOrderConfirmer interface {
|
||||
NotifyNewConversation(conversationID uint64)
|
||||
}
|
||||
|
||||
// QualityOwnerPaymentAdapter 由优质号主模块实现,避免 payment 与 qualityowner 直接耦合。
|
||||
type QualityOwnerPaymentAdapter interface {
|
||||
PaymentAmount(ctx context.Context, userID uint64) (amountCent int64, active bool, err error)
|
||||
ConfirmQualityOwnerDepositPaidTx(tx *gorm.DB, userID uint64, amountCent int64, paymentNo string) error
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
configRepo *paymentconfig.Repository
|
||||
orderRepo *order.Repository
|
||||
mohongRepo MohongOrderConfirmer
|
||||
logger *zap.Logger
|
||||
db *gorm.DB
|
||||
configRepo *paymentconfig.Repository
|
||||
orderRepo *order.Repository
|
||||
mohongRepo MohongOrderConfirmer
|
||||
qualityOwnerRepo QualityOwnerPaymentAdapter
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
type runtimePaymentConfig struct {
|
||||
@@ -96,6 +103,13 @@ func WithMohongRepo(repo MohongOrderConfirmer) RepositoryOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithQualityOwnerRepo 注入优质号主保证金支付适配器。
|
||||
func WithQualityOwnerRepo(repo QualityOwnerPaymentAdapter) RepositoryOption {
|
||||
return func(r *Repository) {
|
||||
r.qualityOwnerRepo = repo
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repository) log() *zap.Logger {
|
||||
if r == nil || r.logger == nil {
|
||||
return zap.NewNop()
|
||||
|
||||
@@ -42,6 +42,16 @@ func (s *Service) StartMohong(ctx context.Context, userID uint64, orderID uint64
|
||||
return s.repo.StartMohong(ctx, userID, orderID, req, clientIP)
|
||||
}
|
||||
|
||||
func (s *Service) StartQualityOwner(ctx context.Context, userID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if userID == 0 {
|
||||
return nil, ErrPaymentCannotStart
|
||||
}
|
||||
return s.repo.StartQualityOwner(ctx, userID, req, clientIP)
|
||||
}
|
||||
|
||||
func (s *Service) Query(ctx context.Context, userID uint64, orderID uint64) (*PaymentDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
@@ -62,6 +72,16 @@ func (s *Service) QueryMohong(ctx context.Context, userID uint64, orderID uint64
|
||||
return s.repo.QueryMohong(ctx, userID, orderID)
|
||||
}
|
||||
|
||||
func (s *Service) QueryQualityOwner(ctx context.Context, userID uint64) (*PaymentDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if userID == 0 {
|
||||
return nil, ErrPaymentNotFound
|
||||
}
|
||||
return s.repo.QueryQualityOwner(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) HandleLeshuaNotify(ctx context.Context, params map[string]string, rawPayload string, contentType string) (*NotifyResult, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
|
||||
@@ -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/model"
|
||||
"hfb_sys/backend/internal/modules/qualityowner"
|
||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||
smsnode "hfb_sys/backend/internal/modules/sms"
|
||||
|
||||
@@ -39,6 +40,7 @@ var defaultConfigs = []defaultConfig{
|
||||
{Key: "order.return_overdue_grace_minutes", Value: "10", Description: "预计截止后结账宽限分钟数"},
|
||||
{Key: "listing.review_required", Value: "false", Description: "发布账号是否需要后台人工审核"},
|
||||
{Key: "listing.publish_cooldown_minutes", Value: "5", Description: "同一用户两次新增发布账号的最小间隔分钟数(0 表示关闭)"},
|
||||
{Key: qualityowner.ConfigKey, Value: qualityowner.DefaultConfigValue(), Description: "优质号主保证金与无忧租号保险费率配置 JSON"},
|
||||
{Key: listingPublishAgreementsConfigKey, Value: defaultListingPublishAgreementsConfigValue(), Description: "发布账号前协议配置 JSON"},
|
||||
{Key: orderAgreementsConfigKey, Value: defaultOrderAgreementsConfigValue(), Description: "下单前协议配置 JSON"},
|
||||
{Key: postRentalNoticeConfigKey, Value: defaultPostRentalNoticeConfigValue(), Description: "租后须知配置 JSON"},
|
||||
@@ -91,6 +93,7 @@ var adminVisibleConfigKeys = []string{
|
||||
"order.pending_payment_timeout_minutes",
|
||||
"order.return_overdue_grace_minutes",
|
||||
"profile.post_rental_notice",
|
||||
qualityowner.ConfigKey,
|
||||
"renter.growth_level_rules",
|
||||
"sms.business_templates",
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/modules/qualityowner"
|
||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||
smsnode "hfb_sys/backend/internal/modules/sms"
|
||||
)
|
||||
@@ -55,6 +56,17 @@ func (s *Service) Update(ctx context.Context, actorID uint64, key string, req Up
|
||||
}
|
||||
req.Value = value
|
||||
}
|
||||
if key == qualityowner.ConfigKey {
|
||||
cfg, err := qualityowner.ParseConfigValue(req.Value)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
value, err := qualityowner.MarshalConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
req.Value = value
|
||||
}
|
||||
return s.repo.Update(ctx, actorID, key, req, meta)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"hfb_sys/backend/internal/modules/paymentaccount"
|
||||
"hfb_sys/backend/internal/modules/paymentconfig"
|
||||
"hfb_sys/backend/internal/modules/pickup"
|
||||
"hfb_sys/backend/internal/modules/qualityowner"
|
||||
"hfb_sys/backend/internal/modules/realname"
|
||||
"hfb_sys/backend/internal/modules/supportgroup"
|
||||
"hfb_sys/backend/internal/modules/systemconfig"
|
||||
@@ -165,6 +166,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
if deps.DB != nil {
|
||||
listingRepo = listing.NewRepository(deps.DB, chatCreator)
|
||||
}
|
||||
var qualityOwnerRepo *qualityowner.Repository
|
||||
if deps.DB != nil {
|
||||
qualityOwnerRepo = qualityowner.NewRepository(deps.DB)
|
||||
}
|
||||
var paymentRepo *payment.Repository
|
||||
var orderRepo *order.Repository
|
||||
if deps.DB != nil {
|
||||
@@ -267,6 +272,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
if mohongRepo != nil {
|
||||
paymentOpts = append(paymentOpts, payment.WithMohongRepo(mohongRepo))
|
||||
}
|
||||
if qualityOwnerRepo != nil {
|
||||
paymentOpts = append(paymentOpts, payment.WithQualityOwnerRepo(qualityOwnerRepo))
|
||||
}
|
||||
paymentRepo = payment.NewRepository(deps.DB, paymentConfigRepo, orderRepo, paymentOpts...)
|
||||
}
|
||||
paymentService := payment.NewService(paymentRepo)
|
||||
@@ -309,6 +317,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
}
|
||||
systemConfigService := systemconfig.NewService(systemConfigRepo)
|
||||
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
|
||||
qualityOwnerService := qualityowner.NewService(qualityOwnerRepo)
|
||||
qualityOwnerHandler := qualityowner.NewHandler(qualityOwnerService)
|
||||
var distributionRepo *distribution.Repository
|
||||
if deps.DB != nil {
|
||||
distributionRepo = distribution.NewRepository(deps.DB, deps.Redis)
|
||||
@@ -477,6 +487,14 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
api.PUT("/me", requireAuth, userHandler.UpdateMe)
|
||||
api.PUT("/password", requireAuth, authHandler.SetPassword)
|
||||
|
||||
qualityOwnerRoutes := api.Group("/quality-owner", requireAuth)
|
||||
{
|
||||
qualityOwnerRoutes.GET("/status", qualityOwnerHandler.Status)
|
||||
qualityOwnerRoutes.POST("/activate", requireRealname, qualityOwnerHandler.Activate)
|
||||
qualityOwnerRoutes.POST("/payment", requireRealname, paymentHandler.StartQualityOwner)
|
||||
qualityOwnerRoutes.GET("/payment", paymentHandler.QueryQualityOwner)
|
||||
}
|
||||
|
||||
if invitationHandler != nil {
|
||||
api.GET("/invitations/code-info", invitationHandler.GetInviteInfo)
|
||||
api.GET("/invitations/code-info/:code", invitationHandler.GetInviteInfo)
|
||||
@@ -768,6 +786,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.POST("/users/:id/manual-realname", requirePerm("user:manual_realname"), adminUserHandler.ManualRealname)
|
||||
adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname)
|
||||
adminRoutes.POST("/users/:id/wallet/adjust", requirePerm("user:wallet_adjust"), adminUserHandler.AdjustWallet)
|
||||
adminRoutes.POST("/users/:id/quality-owner", requirePerm("user:quality_owner"), qualityOwnerHandler.AdminSet)
|
||||
adminRoutes.GET("/orders", requirePerm("order:list"), orderHandler.AdminList)
|
||||
adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing)
|
||||
adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail)
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user