feat(quality-owner): 增加优质号主、极品专区与无忧租号保险
- 支持保证金余额支付、在线支付开通及后台手动设置审计 - 新增极品专区发布权限、赔付方案与保险结算费率快照 - 优化首页、商品卡和个人中心的优质号主身份展示
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user