feat(quality-owner): 增加优质号主、极品专区与无忧租号保险

- 支持保证金余额支付、在线支付开通及后台手动设置审计
- 新增极品专区发布权限、赔付方案与保险结算费率快照
- 优化首页、商品卡和个人中心的优质号主身份展示
This commit is contained in:
yml2213
2026-11-04 15:04:35 +08:00
parent 812e95e7d1
commit 98174c0e33
90 changed files with 3478 additions and 64 deletions
@@ -0,0 +1,85 @@
package qualityowner
import (
"encoding/json"
"errors"
"time"
)
const (
ConfigKey = "quality_owner.rules"
StatusNone = "none"
StatusActive = "active"
DepositSourcePaid = "paid"
DepositSourceManual = "manual"
defaultDepositCent = int64(20_000)
defaultInsuranceBps = 800
defaultPremiumRateBps = 400
)
var ErrInvalidRules = errors.New("invalid quality owner rules")
type Rules struct {
DepositAmountCent int64 `json:"deposit_amount_cent"`
InsuranceFeeBps int `json:"insurance_fee_bps"`
PremiumInsuranceFeeBps int `json:"premium_insurance_fee_bps"`
}
func DefaultRules() Rules {
return Rules{
DepositAmountCent: defaultDepositCent,
InsuranceFeeBps: defaultInsuranceBps,
PremiumInsuranceFeeBps: defaultPremiumRateBps,
}
}
func DefaultConfigValue() string {
raw, _ := json.Marshal(DefaultRules())
return string(raw)
}
func ParseConfigValue(value string) (Rules, error) {
rules := DefaultRules()
if value == "" {
return rules, nil
}
if err := json.Unmarshal([]byte(value), &rules); err != nil {
return Rules{}, ErrInvalidRules
}
if rules.DepositAmountCent <= 0 || rules.DepositAmountCent > 10_000_000 {
return Rules{}, ErrInvalidRules
}
if rules.InsuranceFeeBps < 0 || rules.InsuranceFeeBps > 10_000 {
return Rules{}, ErrInvalidRules
}
if rules.PremiumInsuranceFeeBps < 0 || rules.PremiumInsuranceFeeBps > rules.InsuranceFeeBps {
return Rules{}, ErrInvalidRules
}
return rules, nil
}
func MarshalConfig(rules Rules) (string, error) {
raw, err := json.Marshal(rules)
if err != nil {
return "", err
}
return string(raw), nil
}
type StatusDTO struct {
UserID uint64 `json:"user_id"`
Active bool `json:"active"`
Status string `json:"status"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
DepositSource string `json:"deposit_source"`
ActivatedAt *time.Time `json:"activated_at"`
AvailableBalanceCent int64 `json:"available_balance_cent"`
RequiredDepositCent int64 `json:"required_deposit_cent"`
InsuranceFeeBps int `json:"insurance_fee_bps"`
PremiumInsuranceFeeBps int `json:"premium_insurance_fee_bps"`
}
type AdminSetRequest struct {
Enabled bool `json:"enabled"`
Reason string `json:"reason" binding:"required"`
}
@@ -0,0 +1,30 @@
package qualityowner
import "testing"
func TestParseConfigValueDefaultsAndValidation(t *testing.T) {
rules, err := ParseConfigValue(`{"insurance_fee_bps":800,"premium_insurance_fee_bps":400}`)
if err != nil {
t.Fatalf("ParseConfigValue() error = %v", err)
}
if rules.DepositAmountCent != defaultDepositCent || rules.InsuranceFeeBps != 800 || rules.PremiumInsuranceFeeBps != 400 {
t.Fatalf("unexpected rules: %+v", rules)
}
if _, err := ParseConfigValue(`{"deposit_amount_cent":20000,"insurance_fee_bps":400,"premium_insurance_fee_bps":800}`); err == nil {
t.Fatal("expected premium rate above normal rate to be rejected")
}
if _, err := ParseConfigValue(`{"deposit_amount_cent":0}`); err == nil {
t.Fatal("expected zero deposit to be rejected")
}
}
func TestDefaultConfigValueCanBeParsed(t *testing.T) {
rules, err := ParseConfigValue(DefaultConfigValue())
if err != nil {
t.Fatalf("ParseConfigValue(DefaultConfigValue()) error = %v", err)
}
if rules.DepositAmountCent != 20000 || rules.InsuranceFeeBps != 800 || rules.PremiumInsuranceFeeBps != 400 {
t.Fatalf("unexpected default rules: %+v", rules)
}
}
@@ -0,0 +1,119 @@
package qualityowner
import (
"errors"
"net/http"
"strconv"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) Status(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
item, err := h.service.Status(c.Request.Context(), userID)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) Activate(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
item, err := h.service.Activate(c.Request.Context(), userID)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) AdminSet(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
userID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || userID == 0 {
response.BadRequest(c, "用户 ID 不正确")
return
}
var req AdminSetRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请填写操作原因")
return
}
item, err := h.service.AdminSet(c.Request.Context(), adminID, userID, req, 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 currentUserID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextUserID)
if !ok {
return 0, false
}
id, ok := value.(uint64)
return id, ok
}
func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID)
if !ok {
return 0, false
}
id, ok := value.(uint64)
return id, ok
}
func writeError(c *gin.Context, err error) {
response.RecordError(c, err)
switch {
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库未连接")
case errors.Is(err, ErrInvalidUser):
response.BadRequest(c, "用户不符合开通条件,请先完成实名认证")
case errors.Is(err, ErrDepositInsufficient):
response.Error(c, http.StatusConflict, "insufficient_balance", "余额不足,请先确保可用余额足够缴纳保证金")
case errors.Is(err, ErrAlreadyActive):
response.Error(c, http.StatusConflict, "quality_owner_active", "当前已经是优质号主")
case errors.Is(err, ErrPaymentPending):
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, ErrInvalidReason):
response.BadRequest(c, "请填写 2-200 字的操作原因")
case errors.Is(err, gorm.ErrRecordNotFound):
response.Error(c, http.StatusNotFound, "not_found", "用户不存在")
default:
response.Error(c, http.StatusInternalServerError, "internal_error", "优质号主服务暂时不可用")
}
}
@@ -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,
}
}
@@ -0,0 +1,114 @@
package qualityowner
import (
"testing"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
)
func TestActivateAndAdminDisableRefundDeposit(t *testing.T) {
db := database.NewTestDB()
user := model.User{
Phone: "13900009301",
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)
status, err := repo.Activate(t.Context(), user.ID)
if err != nil {
t.Fatalf("开通优质号主失败: %v", err)
}
if !status.Active || status.DepositAmountCent != 20_000 || status.DepositSource != DepositSourcePaid {
t.Fatalf("开通后的状态不正确: %+v", status)
}
if status.AvailableBalanceCent != 10_000 {
t.Fatalf("可用余额 = %d, want 10000", status.AvailableBalanceCent)
}
if err := db.First(&account, account.ID).Error; err != nil {
t.Fatalf("读取钱包失败: %v", err)
}
if account.FrozenBalanceCent != 20_000 {
t.Fatalf("冻结余额 = %d, want 20000", account.FrozenBalanceCent)
}
status, err = repo.AdminSet(t.Context(), 99, user.ID, AdminSetRequest{
Enabled: false,
Reason: "用户申请取消优质号主",
}, AuditMeta{RequestID: "quality-owner-disable"})
if err != nil {
t.Fatalf("取消优质号主失败: %v", err)
}
if status.Active || status.DepositAmountCent != 0 {
t.Fatalf("取消后的状态不正确: %+v", status)
}
if status.AvailableBalanceCent != 30_000 {
t.Fatalf("退款后可用余额 = %d, want 30000", status.AvailableBalanceCent)
}
if err := db.First(&account, account.ID).Error; err != nil {
t.Fatalf("读取退款后钱包失败: %v", err)
}
if account.FrozenBalanceCent != 0 {
t.Fatalf("退款后冻结余额 = %d, want 0", account.FrozenBalanceCent)
}
var ledgerCount int64
if err := db.Model(&model.WalletLedger{}).Where("user_id = ?", user.ID).Count(&ledgerCount).Error; err != nil {
t.Fatalf("统计钱包流水失败: %v", err)
}
if ledgerCount != 4 {
t.Fatalf("钱包流水条数 = %d, want 4", ledgerCount)
}
var auditCount int64
if err := db.Model(&model.AuditLog{}).
Where("action = ? AND biz_id = ?", "admin_user.set_quality_owner", user.ID).
Count(&auditCount).Error; err != nil {
t.Fatalf("统计审计日志失败: %v", err)
}
if auditCount != 1 {
t.Fatalf("后台设置审计日志条数 = %d, want 1", auditCount)
}
}
func TestAdminCanGrantQualityOwnerWithoutWalletDeposit(t *testing.T) {
db := database.NewTestDB()
user := model.User{
Phone: "13900009302",
RealnameStatus: "verified",
Status: "active",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败: %v", err)
}
repo := NewRepository(db)
status, err := repo.AdminSet(t.Context(), 99, user.ID, AdminSetRequest{
Enabled: true,
Reason: "客服核验后手动开通",
}, AuditMeta{RequestID: "quality-owner-enable"})
if err != nil {
t.Fatalf("后台开通优质号主失败: %v", err)
}
if !status.Active || status.DepositSource != DepositSourceManual || status.DepositAmountCent != 20_000 {
t.Fatalf("后台开通状态不正确: %+v", status)
}
var ledgerCount int64
if err := db.Model(&model.WalletLedger{}).Where("user_id = ?", user.ID).Count(&ledgerCount).Error; err != nil {
t.Fatalf("统计钱包流水失败: %v", err)
}
if ledgerCount != 0 {
t.Fatalf("后台手动开通不应产生钱包流水,实际 %d 条", ledgerCount)
}
}
@@ -0,0 +1,32 @@
package qualityowner
import "context"
type Service struct {
repo *Repository
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Status(ctx context.Context, userID uint64) (*StatusDTO, error) {
if s == nil || s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.Status(ctx, userID)
}
func (s *Service) Activate(ctx context.Context, userID uint64) (*StatusDTO, error) {
if s == nil || s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.Activate(ctx, userID)
}
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
}
return s.repo.AdminSet(ctx, adminID, userID, req, meta)
}