Files
hfb_sys/backend/internal/modules/qualityowner/repository.go
T

476 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,
AgreementTitle: rules.AgreementTitle,
AgreementContent: rules.AgreementContent,
AgreementReadSeconds: rules.AgreementReadSeconds,
}
}