暂存一下,防止丢失
This commit is contained in:
58
internal/application/wallet/change_credit.go
Normal file
58
internal/application/wallet/change_credit.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Package wallet 提供代理主钱包复杂写用例。
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ChangeCreditService 调整既有店铺主钱包实际信用额度。
|
||||
type ChangeCreditService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewChangeCreditService 创建实际信用额度调整服务。
|
||||
func NewChangeCreditService(db *gorm.DB) *ChangeCreditService {
|
||||
return &ChangeCreditService{db: db}
|
||||
}
|
||||
|
||||
// Execute 按主钱包类型和版本条件更新,不修改余额、冻结金额或钱包流水。
|
||||
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64, version int) (*dto.ShopCreditLimitResponse, error) {
|
||||
var result *dto.ShopCreditLimitResponse
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var stored model.AgentWallet
|
||||
if err := tx.Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&stored).Error; err != nil {
|
||||
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
|
||||
}
|
||||
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.ChangeCredit(enabled, limit); err != nil {
|
||||
return err
|
||||
}
|
||||
update := tx.Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND version = ? AND balance::numeric - frozen_balance::numeric + ?::numeric >= 0", stored.ID, constants.AgentWalletTypeMain, version, aggregate.EffectiveCredit()).
|
||||
Updates(map[string]any{"credit_enabled": enabled, "credit_limit": limit, "version": gorm.Expr("version + 1")})
|
||||
if update.Error != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, update.Error, "更新店铺信用额度失败")
|
||||
}
|
||||
if update.RowsAffected != 1 {
|
||||
var current model.AgentWallet
|
||||
if err := tx.Where("id = ? AND wallet_type = ?", stored.ID, constants.AgentWalletTypeMain).First(¤t).Error; err != nil {
|
||||
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
|
||||
}
|
||||
if current.Version != version {
|
||||
return errors.New(errors.CodeConflict, "钱包版本已变化,请刷新后重试")
|
||||
}
|
||||
return errors.New(errors.CodeInsufficientQuota, "当前资金占用无法降低或关闭信用额度")
|
||||
}
|
||||
available, _ := aggregate.AvailableBalance()
|
||||
result = &dto.ShopCreditLimitResponse{ShopID: shopID, WalletID: stored.ID, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: enabled, CreditLimit: limit, AvailableBalance: available, Version: version + 1}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
182
internal/application/wallet/posting.go
Normal file
182
internal/application/wallet/posting.go
Normal file
@@ -0,0 +1,182 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// PostingCommand 描述一次具有稳定业务引用的代理主钱包正向入账。
|
||||
type PostingCommand struct {
|
||||
ShopID uint
|
||||
WalletID uint
|
||||
Amount int64
|
||||
ReferenceType string
|
||||
ReferenceID uint
|
||||
TransactionType string
|
||||
UserID uint
|
||||
Creator uint
|
||||
Remark string
|
||||
Metadata *string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// PostingResult 返回统一入账后的资金快照。
|
||||
type PostingResult struct {
|
||||
WalletID uint
|
||||
BalanceBefore int64
|
||||
BalanceAfter int64
|
||||
Version int
|
||||
AlreadyApplied bool
|
||||
}
|
||||
|
||||
// CreditedEvent 是代理主钱包正向入账成功后的可靠领域事实。
|
||||
type CreditedEvent 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"`
|
||||
}
|
||||
|
||||
// CreditEventWriter 在调用方事务内追加正向入账事件。
|
||||
type CreditEventWriter interface {
|
||||
Append(ctx context.Context, tx *gorm.DB, event CreditedEvent) error
|
||||
}
|
||||
|
||||
// PostingService 统一代理主钱包充值与人工调整入账。
|
||||
type PostingService struct {
|
||||
eventWriter CreditEventWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewPostingService 创建统一代理主钱包入账服务。
|
||||
func NewPostingService(eventWriter CreditEventWriter, now func() time.Time) *PostingService {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &PostingService{eventWriter: eventWriter, now: now}
|
||||
}
|
||||
|
||||
// PostInTx 在调用方事务内完成锁定、入账、唯一流水和 Outbox 事件。
|
||||
func (s *PostingService) PostInTx(ctx context.Context, tx *gorm.DB, command PostingCommand) (PostingResult, error) {
|
||||
if s == nil || s.eventWriter == nil || tx == nil {
|
||||
return PostingResult{}, errors.New(errors.CodeInternalError, "代理主钱包入账能力未完整配置")
|
||||
}
|
||||
if err := validatePostingCommand(command); err != nil {
|
||||
return PostingResult{}, 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 PostingResult{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
|
||||
}
|
||||
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
|
||||
}
|
||||
if command.WalletID > 0 && command.WalletID != stored.ID {
|
||||
return PostingResult{}, errors.New(errors.CodeConflict, "入账业务单与代理主钱包归属不一致")
|
||||
}
|
||||
|
||||
existing, err := findExistingPosting(ctx, tx, command.ReferenceType, command.ReferenceID)
|
||||
if err != nil {
|
||||
return PostingResult{}, err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.AgentWalletID != stored.ID || existing.Amount != command.Amount || existing.TransactionType != command.TransactionType {
|
||||
return PostingResult{}, errors.New(errors.CodeConflict, "业务单已存在不一致的钱包入账流水")
|
||||
}
|
||||
return PostingResult{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.Credit(command.Amount); err != nil {
|
||||
return PostingResult{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
update := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND status = ? AND version = ?", stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version).
|
||||
Updates(map[string]any{"balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": now})
|
||||
if update.Error != nil {
|
||||
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "增加代理主钱包余额失败")
|
||||
}
|
||||
if update.RowsAffected != 1 {
|
||||
return PostingResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
|
||||
}
|
||||
|
||||
referenceType := strings.TrimSpace(command.ReferenceType)
|
||||
remark := strings.TrimSpace(command.Remark)
|
||||
transaction := &model.AgentWalletTransaction{
|
||||
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
|
||||
TransactionType: command.TransactionType, Amount: command.Amount,
|
||||
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Status: constants.TransactionStatusSuccess,
|
||||
ReferenceType: &referenceType, ReferenceID: &command.ReferenceID, Remark: &remark, Metadata: command.Metadata,
|
||||
Creator: command.Creator, ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包入账流水失败")
|
||||
}
|
||||
event := CreditedEvent{
|
||||
EventID: "agent-wallet:" + referenceType + ":" + strconv.FormatUint(uint64(command.ReferenceID), 10) + ":credited",
|
||||
WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount,
|
||||
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1,
|
||||
ReferenceType: referenceType, ReferenceID: command.ReferenceID, TransactionType: command.TransactionType,
|
||||
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
||||
}
|
||||
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
|
||||
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包入账事件失败")
|
||||
}
|
||||
return PostingResult{WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1}, nil
|
||||
}
|
||||
|
||||
func validatePostingCommand(command PostingCommand) error {
|
||||
if command.ShopID == 0 || command.Amount <= 0 || command.ReferenceID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包入账参数无效")
|
||||
}
|
||||
referenceType := strings.TrimSpace(command.ReferenceType)
|
||||
validRecharge := referenceType == constants.ReferenceTypeTopup && command.TransactionType == constants.AgentTransactionTypeRecharge
|
||||
validAdjustment := referenceType == constants.ReferenceTypeManualAdjustment && command.TransactionType == constants.AgentTransactionTypeAdjustment
|
||||
if !validRecharge && !validAdjustment {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包入账业务类型无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findExistingPosting(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 IN ? AND status = ?",
|
||||
strings.TrimSpace(referenceType), referenceID, []string{constants.AgentTransactionTypeRecharge, constants.AgentTransactionTypeAdjustment}, 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, "查询代理主钱包入账流水失败")
|
||||
}
|
||||
289
internal/application/wallet/refund.go
Normal file
289
internal/application/wallet/refund.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"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"
|
||||
)
|
||||
|
||||
// RefundCommand 描述一次沿订单原扣款回溯的代理主钱包退款。
|
||||
type RefundCommand struct {
|
||||
OrderID uint
|
||||
RefundID uint
|
||||
Amount int64
|
||||
LegacyPayerShopID uint
|
||||
LegacyDeductAmount int64
|
||||
LegacyRelatedShopID *uint
|
||||
AssetType string
|
||||
AssetID uint
|
||||
AssetIdentifier string
|
||||
UserID uint
|
||||
Creator uint
|
||||
Remark string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// RefundResult 返回代理主钱包退款后的资金快照。
|
||||
type RefundResult struct {
|
||||
WalletID uint
|
||||
BalanceBefore int64
|
||||
BalanceAfter int64
|
||||
Version int
|
||||
AlreadyApplied bool
|
||||
}
|
||||
|
||||
// RefundedEvent 是代理主钱包订单退款成功后的可靠资金事实。
|
||||
type RefundedEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
WalletID uint `json:"wallet_id"`
|
||||
ShopID uint `json:"shop_id"`
|
||||
OrderID uint `json:"order_id"`
|
||||
RefundID uint `json:"refund_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
BalanceBefore int64 `json:"balance_before"`
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
Version int `json:"version"`
|
||||
OriginalDebitTransactionID uint `json:"original_debit_transaction_id,omitempty"`
|
||||
OriginalDeductAmount int64 `json:"original_deduct_amount"`
|
||||
RelatedShopID *uint `json:"related_shop_id,omitempty"`
|
||||
TransactionSubtype *string `json:"transaction_subtype,omitempty"`
|
||||
AssetType string `json:"asset_type,omitempty"`
|
||||
AssetID uint `json:"asset_id,omitempty"`
|
||||
AssetIdentifier string `json:"asset_identifier,omitempty"`
|
||||
Legacy bool `json:"legacy"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// RefundEventWriter 在调用方事务内追加代理主钱包退款事件。
|
||||
type RefundEventWriter interface {
|
||||
Append(ctx context.Context, tx *gorm.DB, event RefundedEvent) error
|
||||
}
|
||||
|
||||
// RefundService 统一代理订单退款回充、真实流水和可靠事件。
|
||||
type RefundService struct {
|
||||
eventWriter RefundEventWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewRefundService 创建统一代理主钱包退款服务。
|
||||
func NewRefundService(eventWriter RefundEventWriter, now func() time.Time) *RefundService {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &RefundService{eventWriter: eventWriter, now: now}
|
||||
}
|
||||
|
||||
// RefundInTx 在调用方事务内按原扣款事实完成退款回充、版本递增、唯一流水和 Outbox。
|
||||
func (s *RefundService) RefundInTx(ctx context.Context, tx *gorm.DB, command RefundCommand) (RefundResult, error) {
|
||||
if s == nil || s.eventWriter == nil || tx == nil {
|
||||
return RefundResult{}, errors.New(errors.CodeInternalError, "代理主钱包退款能力未完整配置")
|
||||
}
|
||||
if err := validateRefundCommand(command); err != nil {
|
||||
return RefundResult{}, err
|
||||
}
|
||||
|
||||
origin, err := findOriginalOrderDebit(ctx, tx, command.OrderID)
|
||||
if err != nil {
|
||||
return RefundResult{}, err
|
||||
}
|
||||
resolution, err := resolveRefundTarget(command, origin)
|
||||
if err != nil {
|
||||
return RefundResult{}, err
|
||||
}
|
||||
stored, err := lockRefundWallet(ctx, tx, resolution)
|
||||
if err != nil {
|
||||
return RefundResult{}, err
|
||||
}
|
||||
|
||||
existing, err := findExistingRefund(ctx, tx, command.RefundID)
|
||||
if err != nil {
|
||||
return RefundResult{}, err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.AgentWalletID != stored.ID || existing.Amount != command.Amount ||
|
||||
!sameOptionalUint(existing.RelatedShopID, resolution.relatedShopID) ||
|
||||
!sameOptionalString(existing.TransactionSubtype, resolution.subtype) ||
|
||||
existing.AssetType != resolution.assetType || existing.AssetID != resolution.assetID ||
|
||||
existing.AssetIdentifier != resolution.assetIdentifier {
|
||||
return RefundResult{}, errors.New(errors.CodeConflict, "退款单已存在不一致的钱包回充流水")
|
||||
}
|
||||
return RefundResult{
|
||||
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.Credit(command.Amount); err != nil {
|
||||
return RefundResult{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
update := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND status = ? AND version = ?",
|
||||
stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version).
|
||||
Updates(map[string]any{"balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": now})
|
||||
if update.Error != nil {
|
||||
return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "退回代理主钱包余额失败")
|
||||
}
|
||||
if update.RowsAffected != 1 {
|
||||
return RefundResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
|
||||
}
|
||||
|
||||
transaction := buildRefundTransaction(stored, aggregate.Balance, command, resolution)
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包退款流水失败")
|
||||
}
|
||||
event := RefundedEvent{
|
||||
EventID: "agent-wallet:refund:" + strconv.FormatUint(uint64(command.RefundID), 10) + ":refunded",
|
||||
WalletID: stored.ID, ShopID: stored.ShopID, OrderID: command.OrderID, RefundID: command.RefundID,
|
||||
Amount: command.Amount, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
|
||||
Version: stored.Version + 1, OriginalDebitTransactionID: resolution.originalDebitID,
|
||||
OriginalDeductAmount: resolution.deductAmount,
|
||||
RelatedShopID: resolution.relatedShopID, TransactionSubtype: resolution.subtype, Legacy: resolution.legacy,
|
||||
AssetType: resolution.assetType, AssetID: resolution.assetID, AssetIdentifier: resolution.assetIdentifier,
|
||||
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
||||
}
|
||||
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
|
||||
return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包退款事件失败")
|
||||
}
|
||||
return RefundResult{
|
||||
WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
|
||||
Version: stored.Version + 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type refundResolution struct {
|
||||
walletID uint
|
||||
shopID uint
|
||||
originalDebitID uint
|
||||
deductAmount int64
|
||||
relatedShopID *uint
|
||||
subtype *string
|
||||
assetType string
|
||||
assetID uint
|
||||
assetIdentifier string
|
||||
legacy bool
|
||||
}
|
||||
|
||||
func validateRefundCommand(command RefundCommand) error {
|
||||
if command.OrderID == 0 || command.RefundID == 0 || command.Amount <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包退款参数无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findOriginalOrderDebit(ctx context.Context, tx *gorm.DB, orderID uint) (*model.AgentWalletTransaction, error) {
|
||||
var transaction model.AgentWalletTransaction
|
||||
err := tx.WithContext(ctx).Unscoped().
|
||||
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
|
||||
constants.ReferenceTypeOrder, orderID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
|
||||
Order("id ASC").First(&transaction).Error
|
||||
if err == nil {
|
||||
return &transaction, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原代理主钱包扣款流水失败")
|
||||
}
|
||||
|
||||
func resolveRefundTarget(command RefundCommand, origin *model.AgentWalletTransaction) (refundResolution, error) {
|
||||
if origin != nil {
|
||||
if origin.Amount >= 0 || origin.Amount == math.MinInt64 {
|
||||
return refundResolution{}, errors.New(errors.CodeInternalError, "原代理主钱包扣款流水金额非法")
|
||||
}
|
||||
maxAmount := -origin.Amount
|
||||
if command.Amount > maxAmount {
|
||||
return refundResolution{}, errors.New(errors.CodeInvalidParam, "退款金额不能大于原钱包扣款金额")
|
||||
}
|
||||
return refundResolution{
|
||||
walletID: origin.AgentWalletID, originalDebitID: origin.ID, deductAmount: maxAmount,
|
||||
relatedShopID: origin.RelatedShopID, subtype: origin.TransactionSubtype,
|
||||
assetType: origin.AssetType, assetID: origin.AssetID, assetIdentifier: origin.AssetIdentifier,
|
||||
}, nil
|
||||
}
|
||||
if command.LegacyPayerShopID == 0 || command.LegacyDeductAmount <= 0 {
|
||||
return refundResolution{}, errors.New(errors.CodeInternalError, "历史订单缺少原扣款流水和兼容付款快照")
|
||||
}
|
||||
if command.Amount > command.LegacyDeductAmount {
|
||||
return refundResolution{}, errors.New(errors.CodeInvalidParam, "退款金额不能大于历史订单实付金额")
|
||||
}
|
||||
return refundResolution{
|
||||
shopID: command.LegacyPayerShopID, relatedShopID: command.LegacyRelatedShopID,
|
||||
deductAmount: command.LegacyDeductAmount,
|
||||
assetType: command.AssetType, assetID: command.AssetID, assetIdentifier: command.AssetIdentifier,
|
||||
legacy: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func lockRefundWallet(ctx context.Context, tx *gorm.DB, resolution refundResolution) (model.AgentWallet, error) {
|
||||
var stored model.AgentWallet
|
||||
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("wallet_type = ?", constants.AgentWalletTypeMain)
|
||||
if resolution.walletID > 0 {
|
||||
query = query.Where("id = ?", resolution.walletID)
|
||||
} else {
|
||||
query = query.Where("shop_id = ?", resolution.shopID)
|
||||
}
|
||||
if err := query.First(&stored).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return model.AgentWallet{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
|
||||
}
|
||||
return model.AgentWallet{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
func findExistingRefund(ctx context.Context, tx *gorm.DB, refundID uint) (*model.AgentWalletTransaction, error) {
|
||||
var transaction model.AgentWalletTransaction
|
||||
err := tx.WithContext(ctx).Unscoped().
|
||||
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
|
||||
constants.ReferenceTypeRefund, refundID, constants.AgentTransactionTypeRefund, 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 buildRefundTransaction(stored model.AgentWallet, balanceAfter int64, command RefundCommand, resolution refundResolution) *model.AgentWalletTransaction {
|
||||
referenceType := constants.ReferenceTypeRefund
|
||||
remark := strings.TrimSpace(command.Remark)
|
||||
return &model.AgentWalletTransaction{
|
||||
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
|
||||
TransactionType: constants.AgentTransactionTypeRefund, TransactionSubtype: resolution.subtype,
|
||||
Amount: command.Amount, BalanceBefore: stored.Balance, BalanceAfter: balanceAfter,
|
||||
Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.RefundID,
|
||||
RelatedShopID: resolution.relatedShopID, AssetType: resolution.assetType, AssetID: resolution.assetID,
|
||||
AssetIdentifier: resolution.assetIdentifier, Remark: &remark, Creator: command.Creator,
|
||||
ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
|
||||
}
|
||||
}
|
||||
|
||||
func sameOptionalUint(left, right *uint) bool {
|
||||
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
|
||||
}
|
||||
|
||||
func sameOptionalString(left, right *string) bool {
|
||||
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
|
||||
}
|
||||
297
internal/application/wallet/reservation.go
Normal file
297
internal/application/wallet/reservation.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package wallet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ReservationCommand 描述一次具有稳定业务引用的代理主钱包预占操作。
|
||||
type ReservationCommand struct {
|
||||
ShopID uint
|
||||
Amount int64
|
||||
ReferenceType string
|
||||
ReferenceID uint
|
||||
UserID uint
|
||||
Creator uint
|
||||
Subtype string
|
||||
RelatedShopID *uint
|
||||
AssetType string
|
||||
AssetID uint
|
||||
AssetIdentifier string
|
||||
Remark string
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// ReservationEvent 是代理主钱包预占状态变化事件。
|
||||
type ReservationEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
ReservationID uint `json:"reservation_id"`
|
||||
WalletID uint `json:"wallet_id"`
|
||||
ShopID uint `json:"shop_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Status int `json:"status"`
|
||||
ReferenceType string `json:"reference_type"`
|
||||
ReferenceID uint `json:"reference_id"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// ReservationEventWriter 在业务事务内追加预占状态事件。
|
||||
type ReservationEventWriter interface {
|
||||
Append(ctx context.Context, tx *gorm.DB, event ReservationEvent) error
|
||||
}
|
||||
|
||||
// ReservationService 统一代理主钱包冻结、释放与完成扣除。
|
||||
type ReservationService struct {
|
||||
reservationEvents ReservationEventWriter
|
||||
debitEvents DebitEventWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewReservationService 创建代理主钱包预占服务。
|
||||
func NewReservationService(reservationEvents ReservationEventWriter, debitEvents DebitEventWriter, now func() time.Time) *ReservationService {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &ReservationService{reservationEvents: reservationEvents, debitEvents: debitEvents, now: now}
|
||||
}
|
||||
|
||||
// FreezeInTx 在调用方事务内冻结资金并创建唯一预占事实。
|
||||
func (s *ReservationService) FreezeInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error {
|
||||
if err := s.validateFreeze(tx, command); err != nil {
|
||||
return err
|
||||
}
|
||||
wallet, aggregate, err := lockMainWallet(ctx, tx, command.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := findReservation(ctx, tx, command.ReferenceType, command.ReferenceID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.AgentWalletID == wallet.ID && existing.Amount == command.Amount && existing.Status == constants.AgentWalletReservationStatusFrozen {
|
||||
return nil
|
||||
}
|
||||
return errors.New(errors.CodeConflict, "业务引用已存在不一致的钱包预占")
|
||||
}
|
||||
if err := aggregate.Freeze(command.Amount); err != nil {
|
||||
return err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
if err := updateWalletFunds(ctx, tx, wallet, aggregate, now); err != nil {
|
||||
return err
|
||||
}
|
||||
reservation := &model.AgentWalletReservation{
|
||||
AgentWalletID: wallet.ID, ShopID: wallet.ShopID, Amount: command.Amount,
|
||||
Status: constants.AgentWalletReservationStatusFrozen,
|
||||
ReferenceType: command.ReferenceType, ReferenceID: command.ReferenceID, Creator: command.Creator,
|
||||
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(reservation).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包预占事实失败")
|
||||
}
|
||||
return s.appendReservationEvent(ctx, tx, reservation, now, command)
|
||||
}
|
||||
|
||||
// ReleaseInTx 幂等释放处于冻结状态的资金预占。
|
||||
func (s *ReservationService) ReleaseInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error {
|
||||
return s.finish(ctx, tx, command, constants.AgentWalletReservationStatusReleased)
|
||||
}
|
||||
|
||||
// CompleteInTx 完成冻结资金扣除并创建真实扣款流水与扣款事件。
|
||||
func (s *ReservationService) CompleteInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error {
|
||||
return s.finish(ctx, tx, command, constants.AgentWalletReservationStatusCompleted)
|
||||
}
|
||||
|
||||
func (s *ReservationService) finish(ctx context.Context, tx *gorm.DB, command ReservationCommand, targetStatus int) error {
|
||||
if err := s.validateFinish(tx, command); err != nil {
|
||||
return err
|
||||
}
|
||||
reservation, err := findReservation(ctx, tx, command.ReferenceType, command.ReferenceID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reservation == nil {
|
||||
return errors.New(errors.CodeNotFound, "代理主钱包预占事实不存在")
|
||||
}
|
||||
if (command.ShopID > 0 && reservation.ShopID != command.ShopID) || (command.Amount > 0 && reservation.Amount != command.Amount) {
|
||||
return errors.New(errors.CodeConflict, "钱包预占事实与请求不一致")
|
||||
}
|
||||
command.ShopID = reservation.ShopID
|
||||
command.Amount = reservation.Amount
|
||||
if reservation.Status == targetStatus {
|
||||
return nil
|
||||
}
|
||||
if reservation.Status != constants.AgentWalletReservationStatusFrozen {
|
||||
return errors.New(errors.CodeInvalidStatus, "钱包预占已经进入其他终态")
|
||||
}
|
||||
wallet, aggregate, err := lockMainWallet(ctx, tx, command.ShopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wallet.ID != reservation.AgentWalletID {
|
||||
return errors.New(errors.CodeConflict, "钱包预占归属不一致")
|
||||
}
|
||||
if targetStatus == constants.AgentWalletReservationStatusReleased {
|
||||
err = aggregate.Release(command.Amount)
|
||||
} else {
|
||||
err = aggregate.CompleteReserved(command.Amount)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
if err := updateWalletFunds(ctx, tx, wallet, aggregate, now); err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.AgentWalletReservation{}).
|
||||
Where("id = ? AND status = ?", reservation.ID, constants.AgentWalletReservationStatusFrozen).
|
||||
Updates(map[string]any{"status": targetStatus, "completed_at": now, "updated_at": now})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新代理主钱包预占状态失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "钱包预占状态已变化")
|
||||
}
|
||||
reservation.Status = targetStatus
|
||||
reservation.CompletedAt = &now
|
||||
if targetStatus == constants.AgentWalletReservationStatusCompleted {
|
||||
if err := s.createCompletedDebit(ctx, tx, wallet, aggregate, command, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.appendReservationEvent(ctx, tx, reservation, now, command)
|
||||
}
|
||||
|
||||
func (s *ReservationService) validateConfigured(tx *gorm.DB) error {
|
||||
if s == nil || s.reservationEvents == nil || s.debitEvents == nil || tx == nil {
|
||||
return errors.New(errors.CodeInternalError, "代理主钱包预占能力未完整配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ReservationService) validateFreeze(tx *gorm.DB, command ReservationCommand) error {
|
||||
if err := s.validateConfigured(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if command.ShopID == 0 || command.ReferenceType != constants.ReferenceTypeOrder || command.ReferenceID == 0 || command.Amount <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包预占参数无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ReservationService) validateFinish(tx *gorm.DB, command ReservationCommand) error {
|
||||
if err := s.validateConfigured(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if command.ReferenceType != constants.ReferenceTypeOrder || command.ReferenceID == 0 || command.Amount < 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理主钱包预占参数无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lockMainWallet(ctx context.Context, tx *gorm.DB, shopID uint) (model.AgentWallet, *domainwallet.AgentWallet, error) {
|
||||
var wallet model.AgentWallet
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&wallet).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return wallet, nil, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
|
||||
}
|
||||
return wallet, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
|
||||
}
|
||||
aggregate := &domainwallet.AgentWallet{
|
||||
ID: wallet.ID, ShopID: wallet.ShopID, WalletType: wallet.WalletType,
|
||||
Balance: wallet.Balance, FrozenBalance: wallet.FrozenBalance,
|
||||
CreditEnabled: wallet.CreditEnabled, CreditLimit: wallet.CreditLimit,
|
||||
Status: wallet.Status, Version: wallet.Version,
|
||||
}
|
||||
return wallet, aggregate, nil
|
||||
}
|
||||
|
||||
func findReservation(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint, lock bool) (*model.AgentWalletReservation, error) {
|
||||
var reservation model.AgentWalletReservation
|
||||
query := tx.WithContext(ctx)
|
||||
if lock {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
err := query.Where("reference_type = ? AND reference_id = ?", referenceType, referenceID).First(&reservation).Error
|
||||
if err == nil {
|
||||
return &reservation, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包预占事实失败")
|
||||
}
|
||||
|
||||
func updateWalletFunds(ctx context.Context, tx *gorm.DB, stored model.AgentWallet, aggregate *domainwallet.AgentWallet, now time.Time) error {
|
||||
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
Where("id = ? AND wallet_type = ? AND status = ? AND version = ?", stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version).
|
||||
Updates(map[string]any{
|
||||
"balance": aggregate.Balance, "frozen_balance": aggregate.FrozenBalance,
|
||||
"version": gorm.Expr("version + 1"), "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新代理主钱包预占资金失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ReservationService) createCompletedDebit(ctx context.Context, tx *gorm.DB, stored model.AgentWallet, aggregate *domainwallet.AgentWallet, command ReservationCommand, now time.Time) error {
|
||||
referenceType := command.ReferenceType
|
||||
transaction := &model.AgentWalletTransaction{
|
||||
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
|
||||
TransactionType: constants.AgentTransactionTypeDeduct, Amount: -command.Amount,
|
||||
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
|
||||
Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.ReferenceID,
|
||||
RelatedShopID: command.RelatedShopID, AssetType: command.AssetType, AssetID: command.AssetID,
|
||||
AssetIdentifier: command.AssetIdentifier, Remark: &command.Remark, Creator: command.Creator,
|
||||
ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
|
||||
}
|
||||
if command.Subtype != "" {
|
||||
transaction.TransactionSubtype = &command.Subtype
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
||||
return 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: now,
|
||||
RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
||||
}
|
||||
if err := s.debitEvents.Append(ctx, tx, event); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入冻结资金扣款事件失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ReservationService) appendReservationEvent(ctx context.Context, tx *gorm.DB, reservation *model.AgentWalletReservation, now time.Time, command ReservationCommand) error {
|
||||
event := ReservationEvent{
|
||||
EventID: "agent-wallet-reservation:" + strconv.FormatUint(uint64(reservation.ID), 10) + ":" + strconv.Itoa(reservation.Status),
|
||||
ReservationID: reservation.ID, WalletID: reservation.AgentWalletID, ShopID: reservation.ShopID,
|
||||
Amount: reservation.Amount, Status: reservation.Status,
|
||||
ReferenceType: reservation.ReferenceType, ReferenceID: reservation.ReferenceID,
|
||||
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
||||
}
|
||||
if err := s.reservationEvents.Append(ctx, tx, event); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包预占事件失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user