feat(quality-owner): 增加优质号主、极品专区与无忧租号保险
- 支持保证金余额支付、在线支付开通及后台手动设置审计 - 新增极品专区发布权限、赔付方案与保险结算费率快照 - 优化首页、商品卡和个人中心的优质号主身份展示
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user