feat(quality-owner): 增加优质号主自助取消与权益校验

This commit is contained in:
yml2213
2026-11-04 18:54:44 +08:00
parent e9102026ae
commit c00d691301
11 changed files with 396 additions and 49 deletions
@@ -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"`
@@ -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):
@@ -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,
@@ -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)
}
}
@@ -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
+1
View File
@@ -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)
}