暂存一下,防止丢失
This commit is contained in:
202
internal/application/wallet/debit.go
Normal file
202
internal/application/wallet/debit.go
Normal file
@@ -0,0 +1,202 @@
|
||||
// Package wallet 提供代理主钱包复杂写用例。
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// DebitCommand 描述一次具有稳定业务引用的代理主钱包扣款。
|
||||
type DebitCommand struct {
|
||||
ShopID uint
|
||||
Amount int64
|
||||
ReferenceType string
|
||||
ReferenceID uint
|
||||
UserID uint
|
||||
Creator uint
|
||||
TransactionSubtype string
|
||||
RelatedShopID *uint
|
||||
AssetType string
|
||||
AssetID uint
|
||||
AssetIdentifier string
|
||||
Remark string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// DebitResult 返回统一扣款后的资金快照。
|
||||
type DebitResult struct {
|
||||
WalletID uint
|
||||
BalanceBefore int64
|
||||
BalanceAfter int64
|
||||
Version int
|
||||
AlreadyApplied bool
|
||||
}
|
||||
|
||||
// DebitedEvent 是代理主钱包扣款成功后的可靠领域事实。
|
||||
type DebitedEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
WalletID uint `json:"wallet_id"`
|
||||
ShopID uint `json:"shop_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
BalanceBefore int64 `json:"balance_before"`
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
Version int `json:"version"`
|
||||
ReferenceType string `json:"reference_type"`
|
||||
ReferenceID uint `json:"reference_id"`
|
||||
TransactionType string `json:"transaction_type"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// DebitEventWriter 在调用方事务内追加扣款成功事件。
|
||||
type DebitEventWriter interface {
|
||||
Append(ctx context.Context, tx *gorm.DB, event DebitedEvent) error
|
||||
}
|
||||
|
||||
// DebitService 统一代理主钱包扣款、流水与可靠事件。
|
||||
type DebitService struct {
|
||||
eventWriter DebitEventWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewDebitService 创建统一代理主钱包扣款服务。
|
||||
func NewDebitService(eventWriter DebitEventWriter, now func() time.Time) *DebitService {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &DebitService{eventWriter: eventWriter, now: now}
|
||||
}
|
||||
|
||||
// DebitInTx 在调用方事务内完成锁定、扣款、唯一流水和 Outbox 事件。
|
||||
func (s *DebitService) DebitInTx(ctx context.Context, tx *gorm.DB, command DebitCommand) (DebitResult, error) {
|
||||
if s == nil || s.eventWriter == nil || tx == nil {
|
||||
return DebitResult{}, errors.New(errors.CodeInternalError, "代理主钱包扣款能力未完整配置")
|
||||
}
|
||||
if err := validateDebitCommand(command); err != nil {
|
||||
return DebitResult{}, err
|
||||
}
|
||||
|
||||
var stored model.AgentWallet
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("shop_id = ? AND wallet_type = ?", command.ShopID, constants.AgentWalletTypeMain).
|
||||
First(&stored).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return DebitResult{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
|
||||
}
|
||||
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
|
||||
}
|
||||
|
||||
existing, err := findExistingDebit(ctx, tx, command.ReferenceType, command.ReferenceID)
|
||||
if err != nil {
|
||||
return DebitResult{}, err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.AgentWalletID != stored.ID || existing.Amount != -command.Amount {
|
||||
return DebitResult{}, errors.New(errors.CodeConflict, "业务单已存在不一致的钱包扣款流水")
|
||||
}
|
||||
return DebitResult{
|
||||
WalletID: stored.ID, BalanceBefore: existing.BalanceBefore, BalanceAfter: existing.BalanceAfter,
|
||||
Version: stored.Version, AlreadyApplied: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
aggregate := domainwallet.AgentWallet{
|
||||
ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType,
|
||||
Balance: stored.Balance, FrozenBalance: stored.FrozenBalance,
|
||||
CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit,
|
||||
Status: stored.Status, Version: stored.Version,
|
||||
}
|
||||
if err := aggregate.Debit(command.Amount); err != nil {
|
||||
return DebitResult{}, err
|
||||
}
|
||||
|
||||
updatedAt := s.now().UTC()
|
||||
update := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where(`id = ? AND wallet_type = ? AND status = ? AND version = ?
|
||||
AND balance::numeric - frozen_balance::numeric
|
||||
+ CASE WHEN credit_enabled THEN credit_limit::numeric ELSE 0 END >= ?::numeric`,
|
||||
stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version, command.Amount).
|
||||
Updates(map[string]any{
|
||||
"balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": updatedAt,
|
||||
})
|
||||
if update.Error != nil {
|
||||
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "扣减代理主钱包失败")
|
||||
}
|
||||
if update.RowsAffected != 1 {
|
||||
return DebitResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
|
||||
}
|
||||
|
||||
transaction := buildDebitTransaction(stored, aggregate.Balance, command)
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包扣款流水失败")
|
||||
}
|
||||
event := DebitedEvent{
|
||||
EventID: "agent-wallet:order:" + strconv.FormatUint(uint64(command.ReferenceID), 10) + ":debited",
|
||||
WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount,
|
||||
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1,
|
||||
ReferenceType: command.ReferenceType, ReferenceID: command.ReferenceID,
|
||||
TransactionType: constants.AgentTransactionTypeDeduct, OccurredAt: updatedAt,
|
||||
RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
||||
}
|
||||
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
|
||||
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包扣款事件失败")
|
||||
}
|
||||
return DebitResult{
|
||||
WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
|
||||
Version: stored.Version + 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateDebitCommand(command DebitCommand) error {
|
||||
if command.ShopID == 0 || command.Amount <= 0 || command.ReferenceID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包扣款参数无效")
|
||||
}
|
||||
if strings.TrimSpace(command.ReferenceType) != constants.ReferenceTypeOrder {
|
||||
return errors.New(errors.CodeInvalidParam, "当前统一扣款仅支持订单业务")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findExistingDebit(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint) (*model.AgentWalletTransaction, error) {
|
||||
var transaction model.AgentWalletTransaction
|
||||
err := tx.WithContext(ctx).Unscoped().
|
||||
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
|
||||
referenceType, referenceID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
|
||||
First(&transaction).Error
|
||||
if err == nil {
|
||||
return &transaction, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包扣款流水失败")
|
||||
}
|
||||
|
||||
func buildDebitTransaction(stored model.AgentWallet, balanceAfter int64, command DebitCommand) *model.AgentWalletTransaction {
|
||||
referenceType := strings.TrimSpace(command.ReferenceType)
|
||||
remark := strings.TrimSpace(command.Remark)
|
||||
var subtype *string
|
||||
if value := strings.TrimSpace(command.TransactionSubtype); value != "" {
|
||||
subtype = &value
|
||||
}
|
||||
return &model.AgentWalletTransaction{
|
||||
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
|
||||
TransactionType: constants.AgentTransactionTypeDeduct, TransactionSubtype: subtype,
|
||||
Amount: -command.Amount, BalanceBefore: stored.Balance, BalanceAfter: balanceAfter,
|
||||
Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.ReferenceID,
|
||||
RelatedShopID: command.RelatedShopID, AssetType: command.AssetType, AssetID: command.AssetID,
|
||||
AssetIdentifier: command.AssetIdentifier, Remark: &remark, Creator: command.Creator,
|
||||
ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user