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
@@ -48,30 +48,45 @@ func (r *Repository) confirmPaid(ctx context.Context, payment *model.PaymentOrde
var newConvID uint64
var notifyFn func(uint64)
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if payment.OrderID != 0 {
switch payment.BizType {
case "mohong_pay":
if r.mohongRepo == nil {
return ErrDependencyUnavailable
}
convID, err := r.mohongRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID)
if err != nil {
return err
}
newConvID = convID
notifyFn = r.mohongRepo.NotifyNewConversation
default:
if r.orderRepo == nil {
return ErrDependencyUnavailable
}
convID, err := r.orderRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID)
if err != nil {
return err
}
newConvID = convID
notifyFn = r.orderRepo.NotifyNewConversation
if err := distribution.SnapshotPaidOrderTx(tx, payment.OrderID, payment.AmountCent); err != nil {
return err
switch payment.BizType {
case qualityOwnerPayBizType:
if r.qualityOwnerRepo == nil {
return ErrDependencyUnavailable
}
if err := r.qualityOwnerRepo.ConfirmQualityOwnerDepositPaidTx(
tx,
payment.UserID,
payment.AmountCent,
payment.PaymentNo,
); err != nil {
return err
}
default:
if payment.OrderID != 0 {
switch payment.BizType {
case "mohong_pay":
if r.mohongRepo == nil {
return ErrDependencyUnavailable
}
convID, err := r.mohongRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID)
if err != nil {
return err
}
newConvID = convID
notifyFn = r.mohongRepo.NotifyNewConversation
default:
if r.orderRepo == nil {
return ErrDependencyUnavailable
}
convID, err := r.orderRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID)
if err != nil {
return err
}
newConvID = convID
notifyFn = r.orderRepo.NotifyNewConversation
if err := distribution.SnapshotPaidOrderTx(tx, payment.OrderID, payment.AmountCent); err != nil {
return err
}
}
}
}
+3
View File
@@ -21,6 +21,7 @@ type PaymentDTO struct {
PaymentNo string `json:"payment_no"`
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
BizID uint64 `json:"biz_id"`
PaymentConfigID uint64 `json:"payment_config_id"`
Provider string `json:"provider"`
ThirdOrderID string `json:"third_order_id"`
@@ -28,6 +29,7 @@ type PaymentDTO struct {
PayWay string `json:"pay_way"`
JSPayFlag string `json:"jspay_flag"`
AmountCent int64 `json:"amount_cent"`
BizType string `json:"biz_type"`
Status string `json:"status"`
TDCode string `json:"td_code,omitempty"`
JSPayURL string `json:"jspay_url,omitempty"`
@@ -80,6 +82,7 @@ type AdminPaymentDTO struct {
PaymentNo string `json:"payment_no"`
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
BizID uint64 `json:"biz_id"`
UserID uint64 `json:"user_id"`
PaymentConfigID uint64 `json:"payment_config_id"`
UserPhone string `json:"user_phone"`
@@ -111,6 +111,22 @@ func (h *Handler) StartMohong(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) StartQualityOwner(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
var req StartPaymentRequest
_ = c.ShouldBindJSON(&req)
item, err := h.service.StartQualityOwner(c.Request.Context(), userID, req, c.ClientIP())
if err != nil {
writePaymentError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) Query(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
@@ -147,6 +163,20 @@ func (h *Handler) QueryMohong(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) QueryQualityOwner(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
item, err := h.service.QueryQualityOwner(c.Request.Context(), userID)
if err != nil {
writePaymentError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) AdminRefundMohong(c *gin.Context) {
orderID, ok := parseID(c)
if !ok {
@@ -20,6 +20,11 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r
if err != nil {
return nil, err
}
return r.startPreparedPayment(ctx, payment, "租号订单 "+orderRow.OrderNo, orderRow.OrderNo, clientIP)
}
// startPreparedPayment 对已经创建或复用的支付单执行渠道下单、收银台复用或模拟支付。
func (r *Repository) startPreparedPayment(ctx context.Context, payment *model.PaymentOrder, body string, attach string, clientIP string) (*PaymentDTO, error) {
runtimeConfig, err := r.runtimeConfigForPayment(ctx, payment)
if err != nil {
return nil, ErrPaymentUnavailable
@@ -64,8 +69,8 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r
NotifyURL: runtimeConfig.NotifyURL,
JumpURL: runtimeConfig.JumpURL,
ClientIP: clientIP,
Body: "租号订单 " + orderRow.OrderNo,
Attach: orderRow.OrderNo,
Body: body,
Attach: attach,
})
if err != nil {
_ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error())
@@ -19,6 +19,7 @@ func toDTO(payment model.PaymentOrder) PaymentDTO {
PaymentNo: payment.PaymentNo,
OrderID: payment.OrderID,
OrderNo: payment.OrderNo,
BizID: payment.BizID,
PaymentConfigID: payment.PaymentConfigID,
Provider: payment.Provider,
ThirdOrderID: payment.ThirdOrderID,
@@ -26,6 +27,7 @@ func toDTO(payment model.PaymentOrder) PaymentDTO {
PayWay: payment.PayWay,
JSPayFlag: payment.JSPayFlag,
AmountCent: payment.AmountCent,
BizType: payment.BizType,
Status: payment.Status,
TDCode: payment.TDCode,
JSPayURL: payment.JSPayURL,
@@ -0,0 +1,158 @@
package payment
import (
"context"
"fmt"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const qualityOwnerPayBizType = "quality_owner_pay"
// StartQualityOwner 创建优质号主保证金在线支付单,支付成功后自动开通资格并冻结保证金。
func (r *Repository) StartQualityOwner(ctx context.Context, userID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) {
if r.qualityOwnerRepo == nil {
return nil, ErrDependencyUnavailable
}
req.PayWay = normalizePayWay(req.PayWay)
payment, err := r.prepareQualityOwnerPayment(ctx, userID, req)
if err != nil {
return nil, err
}
return r.startPreparedPayment(ctx, payment, "优质号主保证金", payment.OrderNo, clientIP)
}
func (r *Repository) prepareQualityOwnerPayment(ctx context.Context, userID uint64, req StartPaymentRequest) (*model.PaymentOrder, error) {
amountCent, active, err := r.qualityOwnerRepo.PaymentAmount(ctx, userID)
if err != nil {
return nil, err
}
if active || amountCent <= 0 {
return nil, ErrPaymentCannotStart
}
runtimeConfig, err := r.defaultRuntimeConfig(ctx, req.PayWay)
if err != nil {
return nil, err
}
var paymentID uint64
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id").
First(&user, userID).Error; err != nil {
return err
}
var existing model.PaymentOrder
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ? AND biz_type = ? AND biz_id = ?", userID, qualityOwnerPayBizType, userID).
Order("id DESC").
First(&existing).Error
if err == nil {
if canReuseOrderPayment(existing, *runtimeConfig) {
existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, runtimeConfig.PayWay, "ZFBZF")
existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, runtimeConfig.JSPayFlag, "2")
existing.AmountCent = amountCent
existing.PaymentConfigID = firstNonZero(existing.PaymentConfigID, runtimeConfig.ID)
existing.Provider = firstNonEmpty(existing.Provider, runtimeConfig.Provider)
existing.MerchantID = firstNonEmpty(existing.MerchantID, runtimeConfig.MerchantID)
if existing.Provider == "mock" && existing.ProviderOrderID == "" {
existing.ProviderOrderID = "MOCK" + existing.ThirdOrderID
}
if err := tx.Save(&existing).Error; err != nil {
return err
}
paymentID = existing.ID
return nil
}
} else if err != gorm.ErrRecordNotFound {
return err
}
payment, err := newQualityOwnerPayment(userID, amountCent, req, *runtimeConfig)
if err != nil {
return err
}
if err := tx.Create(&payment).Error; err != nil {
return err
}
paymentID = payment.ID
return nil
})
if err != nil {
return nil, err
}
return r.findPaymentByID(ctx, paymentID)
}
func newQualityOwnerPayment(userID uint64, amountCent int64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (model.PaymentOrder, error) {
paymentNo, err := newPaymentNo()
if err != nil {
return model.PaymentOrder{}, err
}
orderNo := fmt.Sprintf("QO%d", userID)
payment := model.PaymentOrder{
PaymentNo: paymentNo,
OrderID: 0,
OrderNo: orderNo,
BizID: userID,
UserID: userID,
PaymentConfigID: runtimeConfig.ID,
Provider: runtimeConfig.Provider,
MerchantID: runtimeConfig.MerchantID,
ThirdOrderID: paymentNo,
PayWay: firstNonEmpty(req.PayWay, runtimeConfig.PayWay, "ZFBZF"),
JSPayFlag: firstNonEmpty(req.JSPayFlag, runtimeConfig.JSPayFlag, "2"),
AmountCent: amountCent,
BizType: qualityOwnerPayBizType,
Status: "created",
}
if runtimeConfig.isMockMode() {
payment.ProviderOrderID = "MOCK" + paymentNo
payment.TDCode = "mock://payment/pay/" + paymentNo
}
return payment, nil
}
func (r *Repository) QueryQualityOwner(ctx context.Context, userID uint64) (*PaymentDTO, error) {
var payment model.PaymentOrder
if err := r.db.WithContext(ctx).
Where("user_id = ? AND biz_type = ? AND biz_id = ?", userID, qualityOwnerPayBizType, userID).
Order("id DESC").
First(&payment).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrPaymentNotFound
}
return nil, err
}
if isOrderPaymentTerminalStatus(payment.Status) {
dto := toDTO(payment)
return &dto, nil
}
runtimeConfig, err := r.runtimeConfigForPayment(ctx, &payment)
if err != nil {
return nil, ErrPaymentUnavailable
}
if runtimeConfig.isMockMode() {
dto := toDTO(payment)
return &dto, nil
}
if runtimeConfig.Channel == nil {
return nil, ErrPaymentUnavailable
}
resp, err := runtimeConfig.Channel.QueryPayment(ctx, payment.ThirdOrderID, payment.ProviderOrderID)
if err != nil {
return nil, err
}
if err := r.applyChannelStatus(ctx, &payment, resp.Status, resp.PayTime, resp.Raw, channelSourceQuery); err != nil {
return nil, err
}
latest, err := r.findPaymentByID(ctx, payment.ID)
if err != nil {
return nil, err
}
dto := toDTO(*latest)
return &dto, nil
}
@@ -0,0 +1,97 @@
package payment
import (
"testing"
"time"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/qualityowner"
)
func TestConfirmQualityOwnerPaymentFreezesDepositAndIsIdempotent(t *testing.T) {
db := database.NewTestDB()
user := model.User{
Phone: "13900009401",
RealnameStatus: "verified",
Status: "active",
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("创建用户失败: %v", err)
}
if err := db.Create(&model.WalletAccount{
UserID: user.ID,
AvailableBalanceCent: 0,
Status: "active",
}).Error; err != nil {
t.Fatalf("创建钱包失败: %v", err)
}
payment := model.PaymentOrder{
PaymentNo: "PAYQUALITYOWNER01",
OrderID: 0,
OrderNo: "QOQUALITY",
BizID: user.ID,
UserID: user.ID,
Provider: "mock",
ThirdOrderID: "PAYQUALITYOWNER01",
AmountCent: 20_000,
BizType: qualityOwnerPayBizType,
Status: "paying",
}
if err := db.Create(&payment).Error; err != nil {
t.Fatalf("创建支付单失败: %v", err)
}
repo := NewRepository(db, nil, nil, WithQualityOwnerRepo(qualityowner.NewRepository(db)))
raw := map[string]string{"status": "2", "third_order_id": payment.ThirdOrderID}
if err := repo.confirmPaid(t.Context(), &payment, "2", time.Now(), raw, channelSourceMock); err != nil {
t.Fatalf("确认保证金支付失败: %v", err)
}
if err := repo.confirmPaid(t.Context(), &payment, "2", time.Now(), raw, channelSourceMock); err != nil {
t.Fatalf("重复确认保证金支付失败: %v", err)
}
var gotUser model.User
if err := db.First(&gotUser, user.ID).Error; err != nil {
t.Fatalf("读取用户失败: %v", err)
}
if gotUser.QualityOwnerStatus != qualityowner.StatusActive || gotUser.QualityOwnerDepositCent != 20_000 {
t.Fatalf("用户未开通优质号主: %+v", gotUser)
}
var account model.WalletAccount
if err := db.Where("user_id = ? AND distribution_site_id = 0", user.ID).First(&account).Error; err != nil {
t.Fatalf("读取钱包失败: %v", err)
}
if account.AvailableBalanceCent != 0 || account.FrozenBalanceCent != 20_000 {
t.Fatalf("保证金钱包余额不正确: %+v", account)
}
var ledgerCount int64
if err := db.Model(&model.WalletLedger{}).
Where("user_id = ? AND biz_type = ?", user.ID, "quality_owner_deposit").
Count(&ledgerCount).Error; err != nil {
t.Fatalf("统计保证金流水失败: %v", err)
}
if ledgerCount != 1 {
t.Fatalf("保证金流水条数 = %d, want 1", ledgerCount)
}
}
func TestNewQualityOwnerPaymentUsesBizIDAndMockCashier(t *testing.T) {
payment, err := newQualityOwnerPayment(88, 20_000, StartPaymentRequest{PayWay: "WXZF"}, runtimePaymentConfig{
Provider: "mock",
MerchantID: "mock-merchant",
PayWay: "WXZF",
JSPayFlag: "2",
})
if err != nil {
t.Fatalf("newQualityOwnerPayment() error = %v", err)
}
if payment.BizType != qualityOwnerPayBizType || payment.BizID != 88 || payment.UserID != 88 {
t.Fatalf("保证金支付单业务字段不正确: %+v", payment)
}
if payment.OrderID != 0 || payment.AmountCent != 20_000 {
t.Fatalf("保证金支付单订单/金额不正确: %+v", payment)
}
if payment.TDCode == "" || payment.ProviderOrderID == "" {
t.Fatalf("mock 支付单未生成收银台: %+v", payment)
}
}
@@ -107,6 +107,7 @@ func (row adminPaymentRow) toDTO() AdminPaymentDTO {
PaymentNo: row.PaymentNo,
OrderID: row.OrderID,
OrderNo: row.OrderNo,
BizID: row.BizID,
UserID: row.UserID,
PaymentConfigID: row.PaymentConfigID,
UserPhone: row.UserPhone,
+19 -5
View File
@@ -19,12 +19,19 @@ type MohongOrderConfirmer interface {
NotifyNewConversation(conversationID uint64)
}
// QualityOwnerPaymentAdapter 由优质号主模块实现,避免 payment 与 qualityowner 直接耦合。
type QualityOwnerPaymentAdapter interface {
PaymentAmount(ctx context.Context, userID uint64) (amountCent int64, active bool, err error)
ConfirmQualityOwnerDepositPaidTx(tx *gorm.DB, userID uint64, amountCent int64, paymentNo string) error
}
type Repository struct {
db *gorm.DB
configRepo *paymentconfig.Repository
orderRepo *order.Repository
mohongRepo MohongOrderConfirmer
logger *zap.Logger
db *gorm.DB
configRepo *paymentconfig.Repository
orderRepo *order.Repository
mohongRepo MohongOrderConfirmer
qualityOwnerRepo QualityOwnerPaymentAdapter
logger *zap.Logger
}
type runtimePaymentConfig struct {
@@ -96,6 +103,13 @@ func WithMohongRepo(repo MohongOrderConfirmer) RepositoryOption {
}
}
// WithQualityOwnerRepo 注入优质号主保证金支付适配器。
func WithQualityOwnerRepo(repo QualityOwnerPaymentAdapter) RepositoryOption {
return func(r *Repository) {
r.qualityOwnerRepo = repo
}
}
func (r *Repository) log() *zap.Logger {
if r == nil || r.logger == nil {
return zap.NewNop()
@@ -42,6 +42,16 @@ func (s *Service) StartMohong(ctx context.Context, userID uint64, orderID uint64
return s.repo.StartMohong(ctx, userID, orderID, req, clientIP)
}
func (s *Service) StartQualityOwner(ctx context.Context, userID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 {
return nil, ErrPaymentCannotStart
}
return s.repo.StartQualityOwner(ctx, userID, req, clientIP)
}
func (s *Service) Query(ctx context.Context, userID uint64, orderID uint64) (*PaymentDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
@@ -62,6 +72,16 @@ func (s *Service) QueryMohong(ctx context.Context, userID uint64, orderID uint64
return s.repo.QueryMohong(ctx, userID, orderID)
}
func (s *Service) QueryQualityOwner(ctx context.Context, userID uint64) (*PaymentDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 {
return nil, ErrPaymentNotFound
}
return s.repo.QueryQualityOwner(ctx, userID)
}
func (s *Service) HandleLeshuaNotify(ctx context.Context, params map[string]string, rawPayload string, contentType string) (*NotifyResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable