473 lines
14 KiB
Go
473 lines
14 KiB
Go
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,
|
|
}
|
|
}
|