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") ErrActivePremiumListings = errors.New("active premium listings exist") ) type ActivePremiumListingsError struct { Count int64 } func (e ActivePremiumListingsError) Error() string { return ErrActivePremiumListings.Error() } func (e ActivePremiumListingsError) Is(target error) bool { return target == ErrActivePremiumListings } 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 } activePremiumListings, err := activePremiumListingCount(r.db.WithContext(ctx), userID) if err != nil { return nil, err } return toStatusDTO(user, account, rules, activePremiumListings), 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) Cancel(ctx context.Context, userID uint64, meta AuditMeta) (*StatusDTO, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } if userID == 0 { return nil, ErrInvalidUser } 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.QualityOwnerStatus != StatusActive { return ErrInactive } beforeDepositCent := user.QualityOwnerDepositCent beforeSource := user.QualityOwnerDepositSource if err := cancelActiveQualityOwnerTx(tx, &user); err != nil { return err } if err := tx.Save(&user).Error; err != nil { return err } return auditlog.Append(tx, auditlog.Entry{ ActorType: "user", ActorID: userID, Action: "quality_owner.cancel", BizType: "user", BizID: &userID, Meta: meta, Detail: map[string]any{ "reason": "用户主动取消优质号主", "before_status": StatusActive, "after_status": user.QualityOwnerStatus, "before_deposit_cent": beforeDepositCent, "after_deposit_cent": user.QualityOwnerDepositCent, "before_deposit_source": beforeSource, "after_deposit_source": user.QualityOwnerDepositSource, }, }) }) 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 err := cancelActiveQualityOwnerTx(tx, &user); 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 activePremiumListingCount(db *gorm.DB, userID uint64) (int64, error) { var count int64 err := db.Model(&model.RentalListing{}). Where( "owner_id = ? AND is_premium = ? AND (status IN ? OR in_transaction = ?)", userID, true, []string{"published", "rented"}, true, ). Count(&count).Error return count, err } func cancelActiveQualityOwnerTx(tx *gorm.DB, user *model.User) error { if tx == nil || user == nil || user.ID == 0 { return ErrInvalidUser } activeListings, err := activePremiumListingCount(tx, user.ID) if err != nil { return err } if activeListings > 0 { return ActivePremiumListingsError{Count: activeListings} } if user.QualityOwnerDepositSource == DepositSourcePaid && user.QualityOwnerDepositCent > 0 { if err := wallet.AppendEntries(tx, wallet.Entry{ UserID: user.ID, Direction: "out", AmountCent: user.QualityOwnerDepositCent, BalanceType: "frozen", BizType: "quality_owner_deposit_refund", BizNo: fmt.Sprintf("QOR%d", user.ID), Remark: "取消优质号主,解冻保证金", }, wallet.Entry{ UserID: user.ID, Direction: "in", AmountCent: user.QualityOwnerDepositCent, BalanceType: "available", BizType: "quality_owner_deposit_refund", BizNo: fmt.Sprintf("QOR%d", user.ID), Remark: "优质号主保证金退回", }, ); err != nil { return err } } user.QualityOwnerStatus = StatusNone user.QualityOwnerDepositCent = 0 user.QualityOwnerDepositSource = "" user.QualityOwnerActivatedAt = nil return tx.Model(&model.RentalListing{}). Where("owner_id = ? AND is_premium = ? AND status IN ?", user.ID, true, []string{"draft", "published"}). Updates(map[string]any{ "status": "offline", "review_status": "none", "review_reason": "优质号主资格已取消", }).Error } func toStatusDTO(user model.User, account model.WalletAccount, rules Rules, activePremiumListings int64) *StatusDTO { active := user.QualityOwnerStatus == StatusActive return &StatusDTO{ UserID: user.ID, Active: active, Status: user.QualityOwnerStatus, CanCancel: active && activePremiumListings == 0, ActivePremiumListings: activePremiumListings, DepositAmountCent: user.QualityOwnerDepositCent, DepositSource: user.QualityOwnerDepositSource, ActivatedAt: user.QualityOwnerActivatedAt, AvailableBalanceCent: account.AvailableBalanceCent, RequiredDepositCent: rules.DepositAmountCent, InsuranceFeeBps: rules.InsuranceFeeBps, PremiumInsuranceFeeBps: rules.PremiumInsuranceFeeBps, } }