feat(quality-owner): 增加优质号主自助取消与权益校验
This commit is contained in:
@@ -70,6 +70,8 @@ type StatusDTO struct {
|
|||||||
UserID uint64 `json:"user_id"`
|
UserID uint64 `json:"user_id"`
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
CanCancel bool `json:"can_cancel"`
|
||||||
|
ActivePremiumListings int64 `json:"active_premium_listing_count"`
|
||||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||||
DepositSource string `json:"deposit_source"`
|
DepositSource string `json:"deposit_source"`
|
||||||
ActivatedAt *time.Time `json:"activated_at"`
|
ActivatedAt *time.Time `json:"activated_at"`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package qualityowner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -48,6 +49,24 @@ func (h *Handler) Activate(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
response.OK(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Cancel(c *gin.Context) {
|
||||||
|
userID, ok := currentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.Cancel(c.Request.Context(), userID, 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 (h *Handler) AdminSet(c *gin.Context) {
|
func (h *Handler) AdminSet(c *gin.Context) {
|
||||||
adminID, ok := currentAdminID(c)
|
adminID, ok := currentAdminID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -109,6 +128,15 @@ func writeError(c *gin.Context, err error) {
|
|||||||
response.Error(c, http.StatusConflict, "quality_owner_payment_pending", "已有待完成的保证金支付,请先完成支付或稍后重试")
|
response.Error(c, http.StatusConflict, "quality_owner_payment_pending", "已有待完成的保证金支付,请先完成支付或稍后重试")
|
||||||
case errors.Is(err, ErrInactive):
|
case errors.Is(err, ErrInactive):
|
||||||
response.Error(c, http.StatusConflict, "quality_owner_inactive", "当前不是优质号主")
|
response.Error(c, http.StatusConflict, "quality_owner_inactive", "当前不是优质号主")
|
||||||
|
case errors.Is(err, ErrActivePremiumListings):
|
||||||
|
var activeErr ActivePremiumListingsError
|
||||||
|
errors.As(err, &activeErr)
|
||||||
|
response.Error(
|
||||||
|
c,
|
||||||
|
http.StatusConflict,
|
||||||
|
"quality_owner_active_premium_listings",
|
||||||
|
fmt.Sprintf("极品专区还有 %d 个进行中账号,请先下架或完成订单后再取消", activeErr.Count),
|
||||||
|
)
|
||||||
case errors.Is(err, ErrInvalidReason):
|
case errors.Is(err, ErrInvalidReason):
|
||||||
response.BadRequest(c, "请填写 2-200 字的操作原因")
|
response.BadRequest(c, "请填写 2-200 字的操作原因")
|
||||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||||
|
|||||||
@@ -23,8 +23,21 @@ var (
|
|||||||
ErrDepositInsufficient = errors.New("quality owner deposit insufficient")
|
ErrDepositInsufficient = errors.New("quality owner deposit insufficient")
|
||||||
ErrInvalidReason = errors.New("invalid reason")
|
ErrInvalidReason = errors.New("invalid reason")
|
||||||
ErrPaymentPending = errors.New("quality owner payment pending")
|
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 AuditMeta = auditlog.Meta
|
||||||
|
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
@@ -72,7 +85,11 @@ func (r *Repository) Status(ctx context.Context, userID uint64) (*StatusDTO, err
|
|||||||
if walletErr != nil && !errors.Is(walletErr, gorm.ErrRecordNotFound) {
|
if walletErr != nil && !errors.Is(walletErr, gorm.ErrRecordNotFound) {
|
||||||
return nil, walletErr
|
return nil, walletErr
|
||||||
}
|
}
|
||||||
return toStatusDTO(user, account, rules), nil
|
activePremiumListings, err := activePremiumListingCount(r.db.WithContext(ctx), userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return toStatusDTO(user, account, rules, activePremiumListings), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaymentAmount 返回当前用户在线支付开通优质号主所需的保证金金额。
|
// PaymentAmount 返回当前用户在线支付开通优质号主所需的保证金金额。
|
||||||
@@ -254,6 +271,53 @@ func (r *Repository) Activate(ctx context.Context, userID uint64) (*StatusDTO, e
|
|||||||
return r.Status(ctx, userID)
|
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) {
|
func (r *Repository) AdminSet(ctx context.Context, adminID uint64, userID uint64, req AdminSetRequest, meta AuditMeta) (*StatusDTO, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
@@ -292,42 +356,7 @@ func (r *Repository) AdminSet(ctx context.Context, adminID uint64, userID uint64
|
|||||||
if beforeStatus != StatusActive {
|
if beforeStatus != StatusActive {
|
||||||
return ErrInactive
|
return ErrInactive
|
||||||
}
|
}
|
||||||
if beforeSource == DepositSourcePaid && beforeDepositCent > 0 {
|
if err := cancelActiveQualityOwnerTx(tx, &user); err != nil {
|
||||||
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,11 +391,76 @@ func (r *Repository) AdminSet(ctx context.Context, adminID uint64, userID uint64
|
|||||||
return r.Status(ctx, userID)
|
return r.Status(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func toStatusDTO(user model.User, account model.WalletAccount, rules Rules) *StatusDTO {
|
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{
|
return &StatusDTO{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Active: user.QualityOwnerStatus == StatusActive,
|
Active: active,
|
||||||
Status: user.QualityOwnerStatus,
|
Status: user.QualityOwnerStatus,
|
||||||
|
CanCancel: active && activePremiumListings == 0,
|
||||||
|
ActivePremiumListings: activePremiumListings,
|
||||||
DepositAmountCent: user.QualityOwnerDepositCent,
|
DepositAmountCent: user.QualityOwnerDepositCent,
|
||||||
DepositSource: user.QualityOwnerDepositSource,
|
DepositSource: user.QualityOwnerDepositSource,
|
||||||
ActivatedAt: user.QualityOwnerActivatedAt,
|
ActivatedAt: user.QualityOwnerActivatedAt,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package qualityowner
|
package qualityowner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/database"
|
"hfb_sys/backend/internal/database"
|
||||||
@@ -112,3 +113,97 @@ func TestAdminCanGrantQualityOwnerWithoutWalletDeposit(t *testing.T) {
|
|||||||
t.Fatalf("后台手动开通不应产生钱包流水,实际 %d 条", ledgerCount)
|
t.Fatalf("后台手动开通不应产生钱包流水,实际 %d 条", ledgerCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCancelQualityOwnerRequiresNoActivePremiumListings(t *testing.T) {
|
||||||
|
db := database.NewTestDB()
|
||||||
|
user := model.User{
|
||||||
|
Phone: "13900009303",
|
||||||
|
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)
|
||||||
|
if _, err := repo.Activate(t.Context(), user.ID); err != nil {
|
||||||
|
t.Fatalf("开通优质号主失败: %v", err)
|
||||||
|
}
|
||||||
|
premiumListing := model.RentalListing{
|
||||||
|
ListingNo: "QO-CANCEL-ACTIVE",
|
||||||
|
AccountID: 1,
|
||||||
|
OwnerID: user.ID,
|
||||||
|
Status: "published",
|
||||||
|
ReviewStatus: "approved",
|
||||||
|
IsPremium: true,
|
||||||
|
}
|
||||||
|
if err := db.Create(&premiumListing).Error; err != nil {
|
||||||
|
t.Fatalf("创建极品专区商品失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := repo.Status(t.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取优质号主状态失败: %v", err)
|
||||||
|
}
|
||||||
|
if status.ActivePremiumListings != 1 || status.CanCancel {
|
||||||
|
t.Fatalf("进行中极品商品统计不正确: %+v", status)
|
||||||
|
}
|
||||||
|
if _, err := repo.Cancel(t.Context(), user.ID, AuditMeta{}); !errors.Is(err, ErrActivePremiumListings) {
|
||||||
|
t.Fatalf("用户取消 error = %v, want ErrActivePremiumListings", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.AdminSet(t.Context(), 99, user.ID, AdminSetRequest{
|
||||||
|
Enabled: false,
|
||||||
|
Reason: "后台尝试取消",
|
||||||
|
}, AuditMeta{}); !errors.Is(err, ErrActivePremiumListings) {
|
||||||
|
t.Fatalf("后台取消 error = %v, want ErrActivePremiumListings", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Model(&premiumListing).Updates(map[string]any{
|
||||||
|
"status": "offline",
|
||||||
|
"review_status": "none",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("下架极品商品失败: %v", err)
|
||||||
|
}
|
||||||
|
draftListing := model.RentalListing{
|
||||||
|
ListingNo: "QO-CANCEL-DRAFT",
|
||||||
|
AccountID: 2,
|
||||||
|
OwnerID: user.ID,
|
||||||
|
Status: "draft",
|
||||||
|
ReviewStatus: "none",
|
||||||
|
IsPremium: true,
|
||||||
|
}
|
||||||
|
if err := db.Create(&draftListing).Error; err != nil {
|
||||||
|
t.Fatalf("创建极品专区草稿失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err = repo.Cancel(t.Context(), user.ID, AuditMeta{RequestID: "quality-owner-self-cancel"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("自动取消优质号主失败: %v", err)
|
||||||
|
}
|
||||||
|
if status.Active || status.DepositAmountCent != 0 || status.AvailableBalanceCent != 30_000 {
|
||||||
|
t.Fatalf("取消后的状态不正确: %+v", status)
|
||||||
|
}
|
||||||
|
var gotDraft model.RentalListing
|
||||||
|
if err := db.First(&gotDraft, draftListing.ID).Error; err != nil {
|
||||||
|
t.Fatalf("读取草稿失败: %v", err)
|
||||||
|
}
|
||||||
|
if gotDraft.Status != "offline" {
|
||||||
|
t.Fatalf("取消后草稿状态 = %s, want offline", gotDraft.Status)
|
||||||
|
}
|
||||||
|
var auditCount int64
|
||||||
|
if err := db.Model(&model.AuditLog{}).
|
||||||
|
Where("action = ? AND biz_id = ?", "quality_owner.cancel", user.ID).
|
||||||
|
Count(&auditCount).Error; err != nil {
|
||||||
|
t.Fatalf("统计自动取消审计日志失败: %v", err)
|
||||||
|
}
|
||||||
|
if auditCount != 1 {
|
||||||
|
t.Fatalf("自动取消审计日志条数 = %d, want 1", auditCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ func (s *Service) Activate(ctx context.Context, userID uint64) (*StatusDTO, erro
|
|||||||
return s.repo.Activate(ctx, userID)
|
return s.repo.Activate(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) Cancel(ctx context.Context, userID uint64, meta AuditMeta) (*StatusDTO, error) {
|
||||||
|
if s == nil || s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
return s.repo.Cancel(ctx, userID, meta)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) AdminSet(ctx context.Context, adminID uint64, userID uint64, req AdminSetRequest, meta AuditMeta) (*StatusDTO, error) {
|
func (s *Service) AdminSet(ctx context.Context, adminID uint64, userID uint64, req AdminSetRequest, meta AuditMeta) (*StatusDTO, error) {
|
||||||
if s == nil || s.repo == nil {
|
if s == nil || s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -491,6 +491,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
{
|
{
|
||||||
qualityOwnerRoutes.GET("/status", qualityOwnerHandler.Status)
|
qualityOwnerRoutes.GET("/status", qualityOwnerHandler.Status)
|
||||||
qualityOwnerRoutes.POST("/activate", requireRealname, qualityOwnerHandler.Activate)
|
qualityOwnerRoutes.POST("/activate", requireRealname, qualityOwnerHandler.Activate)
|
||||||
|
qualityOwnerRoutes.POST("/cancel", qualityOwnerHandler.Cancel)
|
||||||
qualityOwnerRoutes.POST("/payment", requireRealname, paymentHandler.StartQualityOwner)
|
qualityOwnerRoutes.POST("/payment", requireRealname, paymentHandler.StartQualityOwner)
|
||||||
qualityOwnerRoutes.GET("/payment", paymentHandler.QueryQualityOwner)
|
qualityOwnerRoutes.GET("/payment", paymentHandler.QueryQualityOwner)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ async function handleToggleQualityOwner(row: AdminUserItem) {
|
|||||||
const { value } = await ElMessageBox.prompt(
|
const { value } = await ElMessageBox.prompt(
|
||||||
enabling
|
enabling
|
||||||
? '后台手动开通将直接发放优质号主资格并跳过保证金缴纳,请填写原因。'
|
? '后台手动开通将直接发放优质号主资格并跳过保证金缴纳,请填写原因。'
|
||||||
: '取消后用户将无法继续发布到极品专区,已发布的极品商品会下架;用户自缴保证金将退回可用余额。',
|
: '取消前需确保极品专区没有进行中的账号,否则操作会被拒绝;取消后用户自缴保证金将退回可用余额。',
|
||||||
enabling ? '设置优质号主' : '取消优质号主',
|
enabling ? '设置优质号主' : '取消优质号主',
|
||||||
{
|
{
|
||||||
inputPlaceholder: '请填写 2-200 字操作原因',
|
inputPlaceholder: '请填写 2-200 字操作原因',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { setPassword } from '@/features/auth/api/auth'
|
|||||||
import { fetchUserInvitationSummary } from '@/features/invitation/api/invitation'
|
import { fetchUserInvitationSummary } from '@/features/invitation/api/invitation'
|
||||||
import {
|
import {
|
||||||
activateQualityOwner,
|
activateQualityOwner,
|
||||||
|
cancelQualityOwner,
|
||||||
fetchQualityOwnerStatus,
|
fetchQualityOwnerStatus,
|
||||||
queryQualityOwnerPayment,
|
queryQualityOwnerPayment,
|
||||||
startQualityOwnerPayment,
|
startQualityOwnerPayment,
|
||||||
@@ -48,6 +49,7 @@ const availableBalanceCent = ref(0)
|
|||||||
const qualityOwnerStatus = ref<QualityOwnerStatus | null>(null)
|
const qualityOwnerStatus = ref<QualityOwnerStatus | null>(null)
|
||||||
const qualityOwnerLoading = ref(false)
|
const qualityOwnerLoading = ref(false)
|
||||||
const qualityOwnerOnlineLoading = ref(false)
|
const qualityOwnerOnlineLoading = ref(false)
|
||||||
|
const qualityOwnerCancelling = ref(false)
|
||||||
const qualityOwnerPayWaySelect = useMobilePayWaySelect()
|
const qualityOwnerPayWaySelect = useMobilePayWaySelect()
|
||||||
const qualityOwnerCashier = useMobilePaymentCashier({
|
const qualityOwnerCashier = useMobilePaymentCashier({
|
||||||
queryPayment: () => queryQualityOwnerPayment(),
|
queryPayment: () => queryQualityOwnerPayment(),
|
||||||
@@ -215,6 +217,48 @@ async function handleQualityOwnerPaid() {
|
|||||||
await loadBalance()
|
await loadBalance()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCancelQualityOwner() {
|
||||||
|
const status = qualityOwnerStatus.value
|
||||||
|
if (!status?.active) return
|
||||||
|
const activeListings = status.active_premium_listing_count || 0
|
||||||
|
if (activeListings > 0) {
|
||||||
|
showToast({
|
||||||
|
message: `极品专区还有 ${activeListings} 个进行中账号,请先处理`,
|
||||||
|
icon: 'warning-o',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const depositHint =
|
||||||
|
status.deposit_source === 'paid' && status.deposit_amount_cent > 0
|
||||||
|
? `保证金 ¥${formatCent(status.deposit_amount_cent)} 将退回可用余额。`
|
||||||
|
: '该资格由后台开通,无保证金退回。'
|
||||||
|
try {
|
||||||
|
await showDialog({
|
||||||
|
title: '取消优质号主',
|
||||||
|
message: `取消后将立即失去极品专区发布权限。${depositHint}`,
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: '确认取消',
|
||||||
|
cancelButtonText: '暂不取消',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qualityOwnerCancelling.value = true
|
||||||
|
try {
|
||||||
|
qualityOwnerStatus.value = await cancelQualityOwner()
|
||||||
|
await session.loadMe()
|
||||||
|
await loadBalance()
|
||||||
|
showToast({ message: '优质号主已取消', icon: 'passed' })
|
||||||
|
} catch (error) {
|
||||||
|
showToast({
|
||||||
|
message: readError(error, '取消优质号主失败,请先处理极品专区进行中账号'),
|
||||||
|
icon: 'cross',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
qualityOwnerCancelling.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadBalance() {
|
async function loadBalance() {
|
||||||
try {
|
try {
|
||||||
const wallet = await fetchWalletBalance()
|
const wallet = await fetchWalletBalance()
|
||||||
@@ -510,7 +554,20 @@ function resolveAvatarURL(url: string | undefined | null) {
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!qualityOwnerStatus?.active" class="quality-owner-actions">
|
<div v-if="qualityOwnerStatus?.active" class="quality-owner-actions">
|
||||||
|
<van-button
|
||||||
|
block
|
||||||
|
round
|
||||||
|
plain
|
||||||
|
type="danger"
|
||||||
|
:loading="qualityOwnerCancelling"
|
||||||
|
:disabled="qualityOwnerStatus.active_premium_listing_count > 0"
|
||||||
|
@click="handleCancelQualityOwner"
|
||||||
|
>
|
||||||
|
取消优质号主
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="quality-owner-actions">
|
||||||
<van-button
|
<van-button
|
||||||
block
|
block
|
||||||
round
|
round
|
||||||
@@ -536,12 +593,15 @@ function resolveAvatarURL(url: string | undefined | null) {
|
|||||||
微信 / 支付宝支付开通
|
微信 / 支付宝支付开通
|
||||||
</van-button>
|
</van-button>
|
||||||
</div>
|
</div>
|
||||||
<p v-else>
|
<p v-if="qualityOwnerStatus?.active">
|
||||||
{{
|
<template v-if="qualityOwnerStatus.active_premium_listing_count > 0">
|
||||||
qualityOwnerStatus?.deposit_source === 'manual'
|
极品专区还有 {{ qualityOwnerStatus.active_premium_listing_count }}
|
||||||
? '已由后台开通优质号主。'
|
个进行中账号,请先下架或完成订单后再取消。
|
||||||
: '保证金已冻结,取消优质号主资格时退回可用余额。'
|
</template>
|
||||||
}}
|
<template v-else-if="qualityOwnerStatus.deposit_source === 'manual'">
|
||||||
|
已由后台开通优质号主,可随时取消。
|
||||||
|
</template>
|
||||||
|
<template v-else> 保证金已冻结,取消后可退回可用余额。 </template>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { realnameStatusLabel } from '@/shared/utils/statusLabels'
|
|||||||
import { setPassword } from '@/features/auth/api/auth'
|
import { setPassword } from '@/features/auth/api/auth'
|
||||||
import {
|
import {
|
||||||
activateQualityOwner,
|
activateQualityOwner,
|
||||||
|
cancelQualityOwner,
|
||||||
fetchQualityOwnerStatus,
|
fetchQualityOwnerStatus,
|
||||||
queryQualityOwnerPayment,
|
queryQualityOwnerPayment,
|
||||||
startQualityOwnerPayment,
|
startQualityOwnerPayment,
|
||||||
@@ -38,6 +39,7 @@ const router = useRouter()
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const qualityOwnerLoading = ref(false)
|
const qualityOwnerLoading = ref(false)
|
||||||
const qualityOwnerOnlineLoading = ref(false)
|
const qualityOwnerOnlineLoading = ref(false)
|
||||||
|
const qualityOwnerCancelling = ref(false)
|
||||||
const qualityOwnerPayWayVisible = ref(false)
|
const qualityOwnerPayWayVisible = ref(false)
|
||||||
const qualityOwnerStatus = ref<QualityOwnerStatus | null>(null)
|
const qualityOwnerStatus = ref<QualityOwnerStatus | null>(null)
|
||||||
const uploadingAvatar = ref(false)
|
const uploadingAvatar = ref(false)
|
||||||
@@ -154,6 +156,39 @@ async function handleQualityOwnerPaymentSucceeded() {
|
|||||||
await session.loadMe()
|
await session.loadMe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCancelQualityOwner() {
|
||||||
|
const status = qualityOwnerStatus.value
|
||||||
|
if (!status?.active) return
|
||||||
|
const activeListings = status.active_premium_listing_count || 0
|
||||||
|
if (activeListings > 0) {
|
||||||
|
ElMessage.warning(`极品专区还有 ${activeListings} 个进行中账号,请先下架或完成订单`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const depositHint =
|
||||||
|
status.deposit_source === 'paid' && status.deposit_amount_cent > 0
|
||||||
|
? `保证金 ¥${formatCent(status.deposit_amount_cent)} 将退回可用余额。`
|
||||||
|
: '该资格由后台开通,无保证金退回。'
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`取消后将立即失去极品专区发布权限,${depositHint}`, '取消优质号主', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确认取消',
|
||||||
|
cancelButtonText: '暂不取消',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qualityOwnerCancelling.value = true
|
||||||
|
try {
|
||||||
|
qualityOwnerStatus.value = await cancelQualityOwner()
|
||||||
|
await session.loadMe()
|
||||||
|
ElMessage.success('优质号主已取消,极品专区权限已关闭')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '取消优质号主失败,请先处理极品专区进行中账号'))
|
||||||
|
} finally {
|
||||||
|
qualityOwnerCancelling.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
form.nickname = session.nickname || session.displayName
|
form.nickname = session.nickname || session.displayName
|
||||||
form.avatar_url = session.avatarUrl || ''
|
form.avatar_url = session.avatarUrl || ''
|
||||||
@@ -439,11 +474,28 @@ async function savePassword() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="quality-owner-actions">
|
<div class="quality-owner-actions">
|
||||||
<span v-if="qualityOwnerStatus?.active">
|
<span v-if="qualityOwnerStatus?.active">
|
||||||
<template v-if="qualityOwnerStatus.deposit_source === 'manual'">已由后台开通</template>
|
<template v-if="qualityOwnerStatus.active_premium_listing_count > 0">
|
||||||
|
极品专区还有 {{ qualityOwnerStatus.active_premium_listing_count }}
|
||||||
|
个进行中账号,请先处理
|
||||||
|
</template>
|
||||||
|
<template v-else-if="qualityOwnerStatus.deposit_source === 'manual'"
|
||||||
|
>已由后台开通</template
|
||||||
|
>
|
||||||
<template v-else>保证金已冻结</template>
|
<template v-else>保证金已冻结</template>
|
||||||
</span>
|
</span>
|
||||||
<span v-else>保证金取消资格时原路退回余额。</span>
|
<span v-else>保证金取消资格时原路退回余额。</span>
|
||||||
<div v-if="!qualityOwnerStatus?.active" class="quality-owner-action-buttons">
|
<div v-if="qualityOwnerStatus?.active" class="quality-owner-action-buttons">
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
:loading="qualityOwnerCancelling"
|
||||||
|
:disabled="qualityOwnerStatus.active_premium_listing_count > 0"
|
||||||
|
@click="handleCancelQualityOwner"
|
||||||
|
>
|
||||||
|
取消优质号主
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="quality-owner-action-buttons">
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="qualityOwnerLoading"
|
:loading="qualityOwnerLoading"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface QualityOwnerStatus {
|
|||||||
user_id: number
|
user_id: number
|
||||||
active: boolean
|
active: boolean
|
||||||
status: 'none' | 'active' | string
|
status: 'none' | 'active' | string
|
||||||
|
can_cancel: boolean
|
||||||
|
active_premium_listing_count: number
|
||||||
deposit_amount_cent: number
|
deposit_amount_cent: number
|
||||||
deposit_source: 'paid' | 'manual' | '' | string
|
deposit_source: 'paid' | 'manual' | '' | string
|
||||||
activated_at?: string
|
activated_at?: string
|
||||||
@@ -25,6 +27,11 @@ export async function activateQualityOwner() {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cancelQualityOwner() {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<QualityOwnerStatus>>('/quality-owner/cancel')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function startQualityOwnerPayment(payWay: PaymentPayWay) {
|
export async function startQualityOwnerPayment(payWay: PaymentPayWay) {
|
||||||
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>('/quality-owner/payment', {
|
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>('/quality-owner/payment', {
|
||||||
pay_way: payWay,
|
pay_way: payWay,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const AUDIT_ACTION_OPTIONS: AuditOption[] = [
|
|||||||
{ value: 'admin_user.wallet_adjust', label: '调整用户余额', group: '用户', highRisk: true },
|
{ value: 'admin_user.wallet_adjust', label: '调整用户余额', group: '用户', highRisk: true },
|
||||||
{ value: 'admin_user.set_quality_owner', label: '设置优质号主', group: '用户', highRisk: true },
|
{ value: 'admin_user.set_quality_owner', label: '设置优质号主', group: '用户', highRisk: true },
|
||||||
{ value: 'quality_owner.activate', label: '缴纳优质号主保证金', group: '用户', highRisk: true },
|
{ value: 'quality_owner.activate', label: '缴纳优质号主保证金', group: '用户', highRisk: true },
|
||||||
|
{ value: 'quality_owner.cancel', label: '取消优质号主', group: '用户', highRisk: true },
|
||||||
// 商品
|
// 商品
|
||||||
{ value: 'listing.admin_offline', label: '后台下架商品', group: '商品', highRisk: true },
|
{ value: 'listing.admin_offline', label: '后台下架商品', group: '商品', highRisk: true },
|
||||||
{ value: 'listing.mark_abnormal', label: '商品标记异常', group: '商品', highRisk: true },
|
{ value: 'listing.mark_abnormal', label: '商品标记异常', group: '商品', highRisk: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user