diff --git a/backend/internal/modules/qualityowner/dto.go b/backend/internal/modules/qualityowner/dto.go index b20a859..9263ac1 100644 --- a/backend/internal/modules/qualityowner/dto.go +++ b/backend/internal/modules/qualityowner/dto.go @@ -70,6 +70,8 @@ type StatusDTO struct { UserID uint64 `json:"user_id"` Active bool `json:"active"` Status string `json:"status"` + CanCancel bool `json:"can_cancel"` + ActivePremiumListings int64 `json:"active_premium_listing_count"` DepositAmountCent int64 `json:"deposit_amount_cent"` DepositSource string `json:"deposit_source"` ActivatedAt *time.Time `json:"activated_at"` diff --git a/backend/internal/modules/qualityowner/handler.go b/backend/internal/modules/qualityowner/handler.go index 629df9e..298b105 100644 --- a/backend/internal/modules/qualityowner/handler.go +++ b/backend/internal/modules/qualityowner/handler.go @@ -2,6 +2,7 @@ package qualityowner import ( "errors" + "fmt" "net/http" "strconv" @@ -48,6 +49,24 @@ func (h *Handler) Activate(c *gin.Context) { 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) { adminID, ok := currentAdminID(c) if !ok { @@ -109,6 +128,15 @@ func writeError(c *gin.Context, err error) { response.Error(c, http.StatusConflict, "quality_owner_payment_pending", "已有待完成的保证金支付,请先完成支付或稍后重试") case errors.Is(err, ErrInactive): 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): response.BadRequest(c, "请填写 2-200 字的操作原因") case errors.Is(err, gorm.ErrRecordNotFound): diff --git a/backend/internal/modules/qualityowner/repository.go b/backend/internal/modules/qualityowner/repository.go index 8ecd033..c1d4d04 100644 --- a/backend/internal/modules/qualityowner/repository.go +++ b/backend/internal/modules/qualityowner/repository.go @@ -23,8 +23,21 @@ var ( 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 { @@ -72,7 +85,11 @@ func (r *Repository) Status(ctx context.Context, userID uint64) (*StatusDTO, err if walletErr != nil && !errors.Is(walletErr, gorm.ErrRecordNotFound) { 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 返回当前用户在线支付开通优质号主所需的保证金金额。 @@ -254,6 +271,53 @@ func (r *Repository) Activate(ctx context.Context, userID uint64) (*StatusDTO, e 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 @@ -292,42 +356,7 @@ func (r *Repository) AdminSet(ctx context.Context, adminID uint64, userID uint64 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 { + if err := cancelActiveQualityOwnerTx(tx, &user); err != nil { return err } } @@ -362,11 +391,76 @@ func (r *Repository) AdminSet(ctx context.Context, adminID uint64, userID uint64 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{ UserID: user.ID, - Active: user.QualityOwnerStatus == StatusActive, + Active: active, Status: user.QualityOwnerStatus, + CanCancel: active && activePremiumListings == 0, + ActivePremiumListings: activePremiumListings, DepositAmountCent: user.QualityOwnerDepositCent, DepositSource: user.QualityOwnerDepositSource, ActivatedAt: user.QualityOwnerActivatedAt, diff --git a/backend/internal/modules/qualityowner/repository_integration_test.go b/backend/internal/modules/qualityowner/repository_integration_test.go index cd7d3ac..39c2f03 100644 --- a/backend/internal/modules/qualityowner/repository_integration_test.go +++ b/backend/internal/modules/qualityowner/repository_integration_test.go @@ -1,6 +1,7 @@ package qualityowner import ( + "errors" "testing" "hfb_sys/backend/internal/database" @@ -112,3 +113,97 @@ func TestAdminCanGrantQualityOwnerWithoutWalletDeposit(t *testing.T) { 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) + } +} diff --git a/backend/internal/modules/qualityowner/service.go b/backend/internal/modules/qualityowner/service.go index 149e691..33aca2f 100644 --- a/backend/internal/modules/qualityowner/service.go +++ b/backend/internal/modules/qualityowner/service.go @@ -24,6 +24,13 @@ func (s *Service) Activate(ctx context.Context, userID uint64) (*StatusDTO, erro 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) { if s == nil || s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index e9ca7a1..f4702b4 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -491,6 +491,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { { qualityOwnerRoutes.GET("/status", qualityOwnerHandler.Status) qualityOwnerRoutes.POST("/activate", requireRealname, qualityOwnerHandler.Activate) + qualityOwnerRoutes.POST("/cancel", qualityOwnerHandler.Cancel) qualityOwnerRoutes.POST("/payment", requireRealname, paymentHandler.StartQualityOwner) qualityOwnerRoutes.GET("/payment", paymentHandler.QueryQualityOwner) } diff --git a/frontend/src/features/admin/views/AdminUsersView.vue b/frontend/src/features/admin/views/AdminUsersView.vue index 57ce2d7..f8c5a6f 100644 --- a/frontend/src/features/admin/views/AdminUsersView.vue +++ b/frontend/src/features/admin/views/AdminUsersView.vue @@ -327,7 +327,7 @@ async function handleToggleQualityOwner(row: AdminUserItem) { const { value } = await ElMessageBox.prompt( enabling ? '后台手动开通将直接发放优质号主资格并跳过保证金缴纳,请填写原因。' - : '取消后用户将无法继续发布到极品专区,已发布的极品商品会下架;用户自缴保证金将退回可用余额。', + : '取消前需确保极品专区没有进行中的账号,否则操作会被拒绝;取消后用户自缴保证金将退回可用余额。', enabling ? '设置优质号主' : '取消优质号主', { inputPlaceholder: '请填写 2-200 字操作原因', diff --git a/frontend/src/features/auth/views/MobileProfileView.vue b/frontend/src/features/auth/views/MobileProfileView.vue index 0ab4d7f..2994f48 100644 --- a/frontend/src/features/auth/views/MobileProfileView.vue +++ b/frontend/src/features/auth/views/MobileProfileView.vue @@ -20,6 +20,7 @@ import { setPassword } from '@/features/auth/api/auth' import { fetchUserInvitationSummary } from '@/features/invitation/api/invitation' import { activateQualityOwner, + cancelQualityOwner, fetchQualityOwnerStatus, queryQualityOwnerPayment, startQualityOwnerPayment, @@ -48,6 +49,7 @@ const availableBalanceCent = ref(0) const qualityOwnerStatus = ref(null) const qualityOwnerLoading = ref(false) const qualityOwnerOnlineLoading = ref(false) +const qualityOwnerCancelling = ref(false) const qualityOwnerPayWaySelect = useMobilePayWaySelect() const qualityOwnerCashier = useMobilePaymentCashier({ queryPayment: () => queryQualityOwnerPayment(), @@ -215,6 +217,48 @@ async function handleQualityOwnerPaid() { 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() { try { const wallet = await fetchWalletBalance() @@ -510,7 +554,20 @@ function resolveAvatarURL(url: string | undefined | null) { > -
+
+ + 取消优质号主 + +
+
-

- {{ - qualityOwnerStatus?.deposit_source === 'manual' - ? '已由后台开通优质号主。' - : '保证金已冻结,取消优质号主资格时退回可用余额。' - }} +

+ + +

diff --git a/frontend/src/features/auth/views/ProfileView.vue b/frontend/src/features/auth/views/ProfileView.vue index f2bdb16..9c8d8d4 100644 --- a/frontend/src/features/auth/views/ProfileView.vue +++ b/frontend/src/features/auth/views/ProfileView.vue @@ -23,6 +23,7 @@ import { realnameStatusLabel } from '@/shared/utils/statusLabels' import { setPassword } from '@/features/auth/api/auth' import { activateQualityOwner, + cancelQualityOwner, fetchQualityOwnerStatus, queryQualityOwnerPayment, startQualityOwnerPayment, @@ -38,6 +39,7 @@ const router = useRouter() const saving = ref(false) const qualityOwnerLoading = ref(false) const qualityOwnerOnlineLoading = ref(false) +const qualityOwnerCancelling = ref(false) const qualityOwnerPayWayVisible = ref(false) const qualityOwnerStatus = ref(null) const uploadingAvatar = ref(false) @@ -154,6 +156,39 @@ async function handleQualityOwnerPaymentSucceeded() { 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() { form.nickname = session.nickname || session.displayName form.avatar_url = session.avatarUrl || '' @@ -439,11 +474,28 @@ async function savePassword() {
- + + 保证金取消资格时原路退回余额。 -
+
+ + 取消优质号主 + +
+
>('/quality-owner/cancel') + return data.data +} + export async function startQualityOwnerPayment(payWay: PaymentPayWay) { const { data } = await apiClient.post>('/quality-owner/payment', { pay_way: payWay, diff --git a/frontend/src/shared/utils/auditLabels.ts b/frontend/src/shared/utils/auditLabels.ts index bbd55b6..0b62f3d 100644 --- a/frontend/src/shared/utils/auditLabels.ts +++ b/frontend/src/shared/utils/auditLabels.ts @@ -30,6 +30,7 @@ export const AUDIT_ACTION_OPTIONS: AuditOption[] = [ { value: 'admin_user.wallet_adjust', 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.cancel', label: '取消优质号主', group: '用户', highRisk: true }, // 商品 { value: 'listing.admin_offline', label: '后台下架商品', group: '商品', highRisk: true }, { value: 'listing.mark_abnormal', label: '商品标记异常', group: '商品', highRisk: true },